Skip to content

Commit b43191b

Browse files
doquanghuyclaude
andcommitted
fix(workflows): render gate show_file contents in the interactive prompt
The gate step read and recorded `show_file` but never displayed its contents at the interactive prompt, so the operator approved/rejected without seeing the referenced file. Render the file inside the prompt when stdin is a TTY, with a graceful notice for missing/unreadable files. Non-interactive PAUSED behaviour, exit codes, resume semantics, and no-`show_file` output are unchanged. Closes #2809. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed10b32 commit b43191b

2 files changed

Lines changed: 100 additions & 3 deletions

File tree

src/specify_cli/workflows/steps/gate/__init__.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import sys
6+
from pathlib import Path
67
from typing import Any
78

89
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
@@ -48,7 +49,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4849
return StepResult(status=StepStatus.PAUSED, output=output)
4950

5051
# Interactive: prompt the user
51-
choice = self._prompt(message, options)
52+
choice = self._prompt(message, options, show_file)
5253
output["choice"] = choice
5354

5455
if choice in ("reject", "abort"):
@@ -68,10 +69,20 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
6869
return StepResult(status=StepStatus.COMPLETED, output=output)
6970

7071
@staticmethod
71-
def _prompt(message: str, options: list[str]) -> str:
72-
"""Display gate message and prompt for a choice."""
72+
def _prompt(message: str, options: list[str], show_file: str | None = None) -> str:
73+
"""Display gate message and prompt for a choice.
74+
75+
When ``show_file`` names a readable file, its contents are shown
76+
before the options so the operator can review the material the
77+
gate refers to.
78+
"""
7379
print("\n ┌─ Gate ─────────────────────────────────────")
7480
print(f" │ {message}")
81+
if show_file and isinstance(show_file, str):
82+
print(" │")
83+
print(f" │ {show_file}:")
84+
for line in GateStep._read_show_file(show_file):
85+
print(f" │ {line}")
7586
print(" │")
7687
for i, opt in enumerate(options, 1):
7788
print(f" │ [{i}] {opt}")
@@ -90,6 +101,21 @@ def _prompt(message: str, options: list[str]) -> str:
90101
return next(o for o in options if o.lower() == raw.lower())
91102
print(f" Invalid choice. Enter 1-{len(options)} or an option name.")
92103

104+
@staticmethod
105+
def _read_show_file(show_file: str) -> list[str]:
106+
"""Return the lines of ``show_file`` for display.
107+
108+
Returns a short notice instead of raising when the file is
109+
missing or cannot be decoded, so a misconfigured path never
110+
breaks the interactive prompt.
111+
"""
112+
try:
113+
text = Path(show_file).read_text(encoding="utf-8")
114+
except (OSError, UnicodeDecodeError) as exc:
115+
return [f"(could not read file: {exc})"]
116+
lines = text.splitlines()
117+
return lines if lines else ["(file is empty)"]
118+
93119
def validate(self, config: dict[str, Any]) -> list[str]:
94120
errors = super().validate(config)
95121
if "message" not in config:

tests/test_workflows.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,77 @@ def test_validate_invalid_on_reject(self):
822822
})
823823
assert any("on_reject" in e for e in errors)
824824

825+
def test_interactive_prompt_renders_show_file(self, tmp_path, monkeypatch, capsys):
826+
from specify_cli.workflows.steps.gate import GateStep
827+
from specify_cli.workflows.base import StepContext, StepStatus
828+
829+
review = tmp_path / "spec.md"
830+
review.write_text("LINE-ONE\nLINE-TWO\n", encoding="utf-8")
831+
832+
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
833+
monkeypatch.setattr("builtins.input", lambda _prompt="": "1")
834+
835+
step = GateStep()
836+
config = {
837+
"id": "review",
838+
"message": "Review the spec.",
839+
"show_file": str(review),
840+
"options": ["approve", "reject"],
841+
}
842+
result = step.execute(config, StepContext())
843+
out = capsys.readouterr().out
844+
845+
assert "LINE-ONE" in out and "LINE-TWO" in out
846+
assert str(review) in out
847+
assert result.status == StepStatus.COMPLETED
848+
assert result.output["choice"] == "approve"
849+
850+
def test_interactive_prompt_missing_show_file_does_not_crash(
851+
self, tmp_path, monkeypatch, capsys
852+
):
853+
from specify_cli.workflows.steps.gate import GateStep
854+
from specify_cli.workflows.base import StepContext, StepStatus
855+
856+
missing = tmp_path / "does-not-exist.md"
857+
858+
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
859+
monkeypatch.setattr("builtins.input", lambda _prompt="": "1")
860+
861+
step = GateStep()
862+
config = {
863+
"id": "review",
864+
"message": "Review.",
865+
"show_file": str(missing),
866+
"options": ["approve", "reject"],
867+
}
868+
result = step.execute(config, StepContext())
869+
out = capsys.readouterr().out
870+
871+
assert "could not read file" in out
872+
assert result.status == StepStatus.COMPLETED
873+
874+
def test_non_interactive_show_file_still_pauses_without_reading(
875+
self, tmp_path, monkeypatch
876+
):
877+
from specify_cli.workflows.steps.gate import GateStep
878+
from specify_cli.workflows.base import StepContext, StepStatus
879+
880+
review = tmp_path / "spec.md"
881+
review.write_text("CONTENT\n", encoding="utf-8")
882+
883+
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
884+
885+
step = GateStep()
886+
config = {
887+
"id": "review",
888+
"message": "Review.",
889+
"show_file": str(review),
890+
"options": ["approve", "reject"],
891+
}
892+
result = step.execute(config, StepContext())
893+
assert result.status == StepStatus.PAUSED
894+
assert result.output["show_file"] == str(review)
895+
825896

826897
class TestIfThenStep:
827898
"""Test the if/then/else step type."""

0 commit comments

Comments
 (0)