diff --git a/src/draft/command_create.py b/src/draft/command_create.py index 54d431c..3ce7c43 100644 --- a/src/draft/command_create.py +++ b/src/draft/command_create.py @@ -599,6 +599,15 @@ def run(args) -> int: worktree_mode, ) + try: + from draft.steps.implement_spec import original_spec + + label = original_spec.preamble_label(ctx) + if label: + print(f"original-spec: attached from {label}", file=sys.stderr) + except Exception: + pass + # 14. Lifecycle + engine engine = Runner() lifecycle = DraftLifecycle( diff --git a/src/draft/runs.py b/src/draft/runs.py index f6c0ad2..76f0469 100644 --- a/src/draft/runs.py +++ b/src/draft/runs.py @@ -45,6 +45,12 @@ def _run_started_at(run_dir: Path) -> float | None: return None try: return parse_human(started).timestamp() + except (ValueError, TypeError): + pass + try: + from datetime import datetime + + return datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp() except (ValueError, TypeError): return None @@ -119,6 +125,24 @@ def is_run_finished(state: dict) -> bool: return all(s in state.get("completed", []) for s in expected_steps(state)) +def find_original_run_on_branch(project: str, branch: str) -> Path | None: + candidates = [] + for run_dir in project_runs(project): + state = load_state(run_dir) + if state is None: + continue + data = state.get("data", {}) + if data.get("branch") != branch: + continue + if data.get("branch_source") != "new": + continue + started = _run_started_at(run_dir) or 0.0 + candidates.append((started, run_dir)) + if not candidates: + return None + return min(candidates, key=lambda x: x[0])[1] + + def find_active_run_on_branch(project: str, branch: str) -> Path | None: from draft.pipelines import CorruptStateError diff --git a/src/draft/steps/implement_spec/__init__.py b/src/draft/steps/implement_spec/__init__.py index fdc0954..d937961 100644 --- a/src/draft/steps/implement_spec/__init__.py +++ b/src/draft/steps/implement_spec/__init__.py @@ -8,6 +8,7 @@ from pathlib import Path from draft.hooks import HookResult, _run_hook_cmd +from draft.steps.implement_spec import original_spec from draft.steps.implement_spec._live_status import LiveStatusSummarizer from pipeline import Step, StepError from pipeline.runner import TIMEOUT_EXIT @@ -30,7 +31,7 @@ def _render_verify_commands(entries: list[dict]) -> str: return "" block = "\n".join(cmds) return ( - "## Verify commands\n\n" + "## Verified commands\n\n" "Draft will run the following after your changes. " "Run them yourself before finishing if practical.\n\n" f"```bash\n{block}\n```" @@ -39,18 +40,35 @@ def _render_verify_commands(entries: list[dict]) -> str: def _render_prompt(ctx, template: str, verify_commands: str) -> str: spec = ctx.get("spec", "") + spec_section = f"## Current Spec\n\n{spec}" verify_errors = ctx.step_get("implement-spec", "verify_errors", "") if verify_errors: - verify_section = f"## Test failures\n\n{verify_errors}\n\nFix the above failures before committing." + verify_section = f"## Verified errors\n\n{verify_errors}\n\nFix the above failures before committing." else: verify_section = "" + original_spec_section = original_spec.render_original_spec(ctx) return ( - template.replace("{{SPEC}}", spec) - .replace("{{VERIFY_COMMANDS}}", verify_commands) + template.replace("{{VERIFY_COMMANDS}}", verify_commands) + .replace("{{ORIGINAL_SPEC}}", original_spec_section) + .replace("{{SPEC}}", spec_section) .replace("{{VERIFY_ERRORS}}", verify_section) ) +def _log_prompt(log_path, prompt: str, attempt: int, max_attempts: int) -> None: + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + block = ( + f"=== implement-spec prompt (attempt {attempt}/{max_attempts}) @ {ts} ===\n" + f"{prompt}\n" + f"=== end prompt ===\n\n" + ) + try: + with open(log_path, "a", encoding="utf-8") as f: + f.write(block) + except OSError as exc: + print(f"warning: could not write prompt log {log_path}: {exc}", file=sys.stderr) + + def _has_changes(cwd: str) -> bool: result = subprocess.run( ["git", "status", "--porcelain"], @@ -355,8 +373,15 @@ def run(self, ctx, engine, lifecycle, step_metrics): prefix=prefix, ).start() try: + prompt = _render_prompt(ctx, impl_template, verify_commands) + _log_prompt( + ctx.log_path(self.name), + prompt, + attempt=attempt, + max_attempts=cfg["max_retries"], + ) engine.run_llm( - prompt=_render_prompt(ctx, impl_template, verify_commands), + prompt=prompt, cwd=wt_dir, log_path=ctx.log_path(self.name), step_metrics=step_metrics, diff --git a/src/draft/steps/implement_spec/implement_spec.md b/src/draft/steps/implement_spec/implement_spec.md index 85c4676..280e697 100644 --- a/src/draft/steps/implement_spec/implement_spec.md +++ b/src/draft/steps/implement_spec/implement_spec.md @@ -13,7 +13,7 @@ You must not run `git commit`, `git add`, `git push`, `git stash`, or any comman {{VERIFY_COMMANDS}} -## Spec +{{ORIGINAL_SPEC}} {{SPEC}} diff --git a/src/draft/steps/implement_spec/original_spec.py b/src/draft/steps/implement_spec/original_spec.py new file mode 100644 index 0000000..45993b1 --- /dev/null +++ b/src/draft/steps/implement_spec/original_spec.py @@ -0,0 +1,70 @@ +import os +from importlib.resources import files +from pathlib import Path + +from draft.runs import find_original_run_on_branch, load_state + +CASE_NONE = "none" +CASE_PRIOR_RUN = "prior_run" +CASE_OPEN_PR = "open_pr" +CASE_COMMITS_ONLY = "commits_only" + +_CASE_FILES = { + CASE_PRIOR_RUN: "original_spec_prior_run.md", + CASE_OPEN_PR: "original_spec_open_pr.md", + CASE_COMMITS_ONLY: "original_spec_commits_only.md", +} + + +def _load_case_template(case: str) -> str: + return files("draft.steps.implement_spec").joinpath(_CASE_FILES[case]).read_text() + + +def _render_case(case: str, mapping: dict) -> str: + template = _load_case_template(case) + for key, value in mapping.items(): + template = template.replace(f"{{{{{key}}}}}", value) + return template + + +def resolve_case(ctx) -> tuple[str, dict]: + if ctx.get("branch_source") != "existing": + return (CASE_NONE, {}) + original_run = find_original_run_on_branch(ctx.get("project"), ctx.get("branch")) + if original_run is not None: + state = load_state(original_run) + spec_path = state.get("data", {}).get("spec") if state else None + if spec_path and Path(spec_path).is_file() and os.access(spec_path, os.R_OK): + return ( + CASE_PRIOR_RUN, + { + "ORIGINAL_SPEC_PATH": spec_path, + "ORIGINAL_RUN_ID": original_run.name, + }, + ) + pr_url = ctx.get("pr_url") + if pr_url: + return (CASE_OPEN_PR, {"PR_URL": pr_url}) + return ( + CASE_COMMITS_ONLY, + { + "BRANCH": ctx.get("branch") or "", + "BASE_BRANCH": ctx.get("base_branch") or "origin/main", + }, + ) + + +def render_original_spec(ctx) -> str: + case, mapping = resolve_case(ctx) + if case == CASE_NONE: + return "" + return _render_case(case, mapping) + + +def preamble_label(ctx) -> str | None: + case, mapping = resolve_case(ctx) + if case == CASE_PRIOR_RUN: + return f"run {mapping['ORIGINAL_RUN_ID']}" + if case == CASE_OPEN_PR: + return f"PR {mapping['PR_URL']}" + return None diff --git a/src/draft/steps/implement_spec/original_spec_commits_only.md b/src/draft/steps/implement_spec/original_spec_commits_only.md new file mode 100644 index 0000000..f8b8d11 --- /dev/null +++ b/src/draft/steps/implement_spec/original_spec_commits_only.md @@ -0,0 +1,10 @@ +## Original spec + +No prior run or open PR was found. Review the branch commits for context. + +```bash +git log {{BASE_BRANCH}}..HEAD --oneline +git show +``` + +Branch: `{{BRANCH}}` diff --git a/src/draft/steps/implement_spec/original_spec_open_pr.md b/src/draft/steps/implement_spec/original_spec_open_pr.md new file mode 100644 index 0000000..f9dab23 --- /dev/null +++ b/src/draft/steps/implement_spec/original_spec_open_pr.md @@ -0,0 +1,9 @@ +## Original spec + +This branch has an open pull request. Review it to understand the original intent. + +```bash +gh pr view {{PR_URL}} +``` + +PR: {{PR_URL}} diff --git a/src/draft/steps/implement_spec/original_spec_prior_run.md b/src/draft/steps/implement_spec/original_spec_prior_run.md new file mode 100644 index 0000000..ca02279 --- /dev/null +++ b/src/draft/steps/implement_spec/original_spec_prior_run.md @@ -0,0 +1,5 @@ +## Original spec + +This branch was started in run `{{ORIGINAL_RUN_ID}}` from the spec at: + +{{ORIGINAL_SPEC_PATH}} diff --git a/tests/draft/test_code_spec.py b/tests/draft/test_code_spec.py index cb33c5e..628635b 100644 --- a/tests/draft/test_code_spec.py +++ b/tests/draft/test_code_spec.py @@ -11,6 +11,7 @@ _format_suggested_failures, _generate_commit_message, _load_template, + _log_prompt, _parse_suggestions, _render_prompt, _render_verify_commands, @@ -120,7 +121,7 @@ def test_render_prompt_empty_verify_commands_collapses_marker(tmp_path): prompt = _render_prompt(ctx, template, "") assert "{{VERIFY_COMMANDS}}" not in prompt - assert "## Verify commands" not in prompt + assert "## Verified commands" not in prompt def test_render_prompt_template_without_verify_commands_marker(tmp_path): @@ -179,7 +180,7 @@ def test_render_verify_commands_none_like_input(): def test_render_verify_commands_single_entry(): result = _render_verify_commands([{"cmd": "make test"}]) - assert "## Verify commands" in result + assert "## Verified commands" in result assert "Run them yourself before finishing if practical" in result assert "```bash" in result assert "make test" in result @@ -334,7 +335,7 @@ def test_run_prompt_contains_verify_commands_when_configured(tmp_path): step.run(ctx, engine, lifecycle, MagicMock()) prompt = engine.run_llm.call_args[1]["prompt"] - assert "## Verify commands" in prompt + assert "## Verified commands" in prompt assert "make test" in prompt @@ -1466,3 +1467,189 @@ def test_bundled_summarize_status_has_tail_placeholder(): files("draft.steps.implement_spec").joinpath("summarize_status.md").read_text() ) assert "{{TAIL}}" in content + + +# --- heading rename tests --- + + +def test_render_verify_commands_heading_is_verified_commands(): + result = _render_verify_commands([{"cmd": "make lint"}]) + assert result.startswith("## Verified commands") + + +def test_render_prompt_verify_errors_section_uses_verified_errors_heading(tmp_path): + tpl = tmp_path / "tpl.md" + tpl.write_text("{{SPEC}}\n{{VERIFY_ERRORS}}") + template = tpl.read_text() + + ctx = MagicMock() + ctx.get.return_value = "spec" + ctx.step_get.return_value = "some error" + + prompt = _render_prompt(ctx, template, "") + assert "## Verified errors" in prompt + assert "## Test failures" not in prompt + + +# --- _render_prompt four-placeholder tests --- + + +def test_render_prompt_substitutes_spec_with_current_spec_heading(tmp_path): + tpl = tmp_path / "tpl.md" + tpl.write_text("{{SPEC}}") + template = tpl.read_text() + + ctx = MagicMock() + ctx.get.return_value = "/path/to/spec.md" + ctx.step_get.return_value = "" + + prompt = _render_prompt(ctx, template, "") + assert "## Current Spec" in prompt + assert "/path/to/spec.md" in prompt + assert "{{SPEC}}" not in prompt + + +def test_render_prompt_never_contains_spec_placeholder(): + bundled = _load_template({}) + ctx = MagicMock() + ctx.get.return_value = "the spec" + ctx.step_get.return_value = "" + + prompt = _render_prompt(ctx, bundled, "") + assert "{{SPEC}}" not in prompt + assert "## Current Spec" in prompt + + +def test_render_prompt_substitutes_original_spec(tmp_path): + tpl = tmp_path / "tpl.md" + tpl.write_text( + "{{ORIGINAL_SPEC}}\n{{SPEC}}\n{{VERIFY_COMMANDS}}\n{{VERIFY_ERRORS}}" + ) + template = tpl.read_text() + + ctx = MagicMock() + ctx.get.side_effect = lambda key, default=None: { + "spec": "my-spec", + "branch_source": "existing", + "branch": "my-branch", + "project": "proj", + "base_branch": "origin/main", + }.get(key, default) + ctx.step_get.return_value = "" + + from unittest.mock import patch + + with patch( + "draft.steps.implement_spec.original_spec.render_original_spec", + return_value="## Original spec\n\nsome context", + ): + prompt = _render_prompt(ctx, template, "") + + assert "## Original spec" in prompt + assert "some context" in prompt + assert "{{ORIGINAL_SPEC}}" not in prompt + + +def test_render_prompt_custom_template_without_original_spec_still_runs(tmp_path): + tpl = tmp_path / "tpl.md" + tpl.write_text("{{SPEC}}\n{{VERIFY_ERRORS}}") + template = tpl.read_text() + + ctx = MagicMock() + ctx.get.return_value = "spec-content" + ctx.step_get.return_value = "" + + prompt = _render_prompt(ctx, template, "") + assert "spec-content" in prompt + assert "{{SPEC}}" not in prompt + + +# --- _log_prompt tests --- + + +def test_log_prompt_appends_bracketed_block(tmp_path): + log_path = tmp_path / "implement-spec.log" + _log_prompt(log_path, "my prompt text", attempt=1, max_attempts=3) + content = log_path.read_text() + assert "=== implement-spec prompt (attempt 1/3) @" in content + assert "my prompt text" in content + assert "=== end prompt ===" in content + + +def test_log_prompt_two_retries_produce_two_blocks(tmp_path): + log_path = tmp_path / "implement-spec.log" + _log_prompt(log_path, "first prompt", attempt=1, max_attempts=2) + _log_prompt(log_path, "second prompt", attempt=2, max_attempts=2) + content = log_path.read_text() + assert content.index("attempt 1/2") < content.index("attempt 2/2") + assert "first prompt" in content + assert "second prompt" in content + + +def test_log_prompt_oserror_prints_warning_and_continues(tmp_path, capsys): + log_path = tmp_path / "implement-spec.log" + + with patch("builtins.open", side_effect=OSError("permission denied")): + _log_prompt(log_path, "prompt", attempt=1, max_attempts=1) + + captured = capsys.readouterr() + assert "warning" in captured.err + assert "permission denied" in captured.err + + +def test_log_prompt_called_before_run_llm(tmp_path): + cfg = {"max_retries": 1, "timeout": 60} + log_path = tmp_path / "implement-spec.log" + ctx = _make_ctx(cfg, tmp_path=tmp_path) + ctx.log_path.return_value = log_path + lifecycle = MagicMock() + lifecycle.run_hooks.return_value = [] + + call_order = [] + + def fake_log_prompt(*a, **kw): + call_order.append("log") + + llm_result = MagicMock() + llm_result.rc = 0 + llm_result.final_text = "commit msg" + + def fake_run_llm(*a, **kw): + call_order.append("llm") + return llm_result + + engine = MagicMock() + stage_ctx = MagicMock() + stage_ctx.__enter__ = MagicMock(return_value=MagicMock()) + stage_ctx.__exit__ = MagicMock(return_value=False) + engine.stage.return_value = stage_ctx + engine.run_llm.side_effect = fake_run_llm + + step = ImplementSpecStep() + with ( + patch("draft.steps.implement_spec._has_changes", return_value=True), + patch( + "draft.steps.implement_spec._log_prompt", side_effect=fake_log_prompt + ) as mock_log, + patch( + "draft.steps.implement_spec._generate_commit_message", + return_value=("msg", False), + ), + patch("draft.steps.implement_spec._run_git_capture", return_value="sha\n"), + patch( + "draft.steps.implement_spec._run_git_capture_allow_fail", + return_value=subprocess.CompletedProcess([], 0, b"", b""), + ), + ): + step.run(ctx, engine, lifecycle, MagicMock()) + + assert call_order == ["log", "llm"] + mock_log.assert_called_once() + + +# --- bundled template markers --- + + +def test_bundled_implement_spec_has_original_spec_marker(): + bundled = _load_template({}) + assert "{{ORIGINAL_SPEC}}" in bundled diff --git a/tests/draft/test_commands.py b/tests/draft/test_commands.py index 0443fa7..6d671e4 100644 --- a/tests/draft/test_commands.py +++ b/tests/draft/test_commands.py @@ -3255,3 +3255,75 @@ def test_init_cli_has_init_subcommand(): ci.register(subs) choices = subs.choices assert "init" in choices + + +# --- command_create preamble emission --- + + +def test_command_create_preamble_emitted_when_label_returned(capsys): + import sys + from unittest.mock import MagicMock, patch + + ctx = MagicMock() + + with patch( + "draft.steps.implement_spec.original_spec.preamble_label", + return_value="run 250101-120000", + ): + try: + from draft.steps.implement_spec import original_spec + + label = original_spec.preamble_label(ctx) + if label: + print(f"original-spec: attached from {label}", file=sys.stderr) + except Exception: + pass + + captured = capsys.readouterr() + assert "original-spec: attached from run 250101-120000" in captured.err + + +def test_command_create_preamble_suppressed_when_resolver_raises(capsys): + import sys + from unittest.mock import MagicMock, patch + + ctx = MagicMock() + + with patch( + "draft.steps.implement_spec.original_spec.preamble_label", + side_effect=Exception("resolver boom"), + ): + try: + from draft.steps.implement_spec import original_spec + + label = original_spec.preamble_label(ctx) + if label: + print(f"original-spec: attached from {label}", file=sys.stderr) + except Exception: + pass + + captured = capsys.readouterr() + assert "original-spec" not in captured.err + + +def test_command_create_preamble_silent_for_none_label(capsys): + import sys + from unittest.mock import MagicMock, patch + + ctx = MagicMock() + + with patch( + "draft.steps.implement_spec.original_spec.preamble_label", + return_value=None, + ): + try: + from draft.steps.implement_spec import original_spec + + label = original_spec.preamble_label(ctx) + if label: + print(f"original-spec: attached from {label}", file=sys.stderr) + except Exception: + pass + + captured = capsys.readouterr() + assert "original-spec" not in captured.err diff --git a/tests/draft/test_original_spec.py b/tests/draft/test_original_spec.py new file mode 100644 index 0000000..5f95f8a --- /dev/null +++ b/tests/draft/test_original_spec.py @@ -0,0 +1,339 @@ +import json +from unittest.mock import MagicMock, patch + +from draft.steps.implement_spec.original_spec import ( + CASE_COMMITS_ONLY, + CASE_NONE, + CASE_OPEN_PR, + CASE_PRIOR_RUN, + preamble_label, + render_original_spec, + resolve_case, +) + + +def _make_ctx( + branch_source=None, + branch="my-branch", + project="myproject", + pr_url=None, + base_branch=None, +): + ctx = MagicMock() + data = { + "branch_source": branch_source, + "branch": branch, + "project": project, + "pr_url": pr_url, + "base_branch": base_branch, + } + ctx.get.side_effect = lambda key, default=None: data.get(key, default) + return ctx + + +def _make_run(runs_dir, project, run_id, state_data, sessions=None): + run_dir = runs_dir / project / run_id + run_dir.mkdir(parents=True) + state = {"data": state_data} + if sessions: + state["sessions"] = sessions + (run_dir / "state.json").write_text(json.dumps(state)) + return run_dir + + +# --- resolve_case --- + + +def test_resolve_case_new_branch_returns_none(): + ctx = _make_ctx(branch_source="new") + case, mapping = resolve_case(ctx) + assert case == CASE_NONE + assert mapping == {} + + +def test_resolve_case_no_branch_source_returns_none(): + ctx = _make_ctx(branch_source=None) + case, mapping = resolve_case(ctx) + assert case == CASE_NONE + + +def test_resolve_case_existing_branch_prior_run_readable_spec(tmp_path): + spec_file = tmp_path / "spec.md" + spec_file.write_text("my spec") + + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-120000", + {"branch": "my-branch", "branch_source": "new", "spec": str(spec_file)}, + sessions=[{"started_at": "2025-01-01T12:00:00Z"}], + ) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, mapping = resolve_case(ctx) + + assert case == CASE_PRIOR_RUN + assert mapping["ORIGINAL_SPEC_PATH"] == str(spec_file) + assert mapping["ORIGINAL_RUN_ID"] == "250101-120000" + + +def test_resolve_case_existing_branch_prior_run_spec_deleted_has_pr(tmp_path): + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-120000", + { + "branch": "my-branch", + "branch_source": "new", + "spec": str(tmp_path / "gone.md"), + }, + ) + + pr_url = "https://github.com/org/repo/pull/42" + ctx = _make_ctx( + branch_source="existing", branch="my-branch", project="myproject", pr_url=pr_url + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, mapping = resolve_case(ctx) + + assert case == CASE_OPEN_PR + assert mapping["PR_URL"] == pr_url + + +def test_resolve_case_existing_branch_no_prior_run_no_pr(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + ctx = _make_ctx( + branch_source="existing", + branch="my-branch", + project="myproject", + base_branch="origin/main", + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, mapping = resolve_case(ctx) + + assert case == CASE_COMMITS_ONLY + assert mapping["BRANCH"] == "my-branch" + assert mapping["BASE_BRANCH"] == "origin/main" + + +def test_resolve_case_commits_only_default_base_branch(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + ctx = _make_ctx( + branch_source="existing", + branch="my-branch", + project="myproject", + base_branch=None, + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, mapping = resolve_case(ctx) + + assert case == CASE_COMMITS_ONLY + assert mapping["BASE_BRANCH"] == "origin/main" + + +def test_resolve_case_picks_earliest_of_two_prior_runs(tmp_path): + spec_file = tmp_path / "spec.md" + spec_file.write_text("spec") + + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-200000", + {"branch": "my-branch", "branch_source": "new", "spec": str(spec_file)}, + sessions=[{"started_at": "2025-01-01T20:00:00Z"}], + ) + _make_run( + runs_dir, + "myproject", + "250101-080000", + {"branch": "my-branch", "branch_source": "new", "spec": str(spec_file)}, + sessions=[{"started_at": "2025-01-01T08:00:00Z"}], + ) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, mapping = resolve_case(ctx) + + assert case == CASE_PRIOR_RUN + assert mapping["ORIGINAL_RUN_ID"] == "250101-080000" + + +def test_resolve_case_corrupt_json_skipped_falls_through_to_commits_only(tmp_path): + runs_dir = tmp_path / "runs" + run_dir = runs_dir / "myproject" / "250101-120000" + run_dir.mkdir(parents=True) + (run_dir / "state.json").write_text("not json{{{{") + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, _ = resolve_case(ctx) + + assert case == CASE_COMMITS_ONLY + + +def test_resolve_case_missing_branch_source_treated_as_non_match(tmp_path): + spec_file = tmp_path / "spec.md" + spec_file.write_text("spec") + + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-120000", + {"branch": "my-branch", "spec": str(spec_file)}, + ) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + case, _ = resolve_case(ctx) + + assert case == CASE_COMMITS_ONLY + + +# --- render_original_spec --- + + +def test_render_original_spec_none_returns_empty_string(): + ctx = _make_ctx(branch_source="new") + result = render_original_spec(ctx) + assert result == "" + + +def test_render_original_spec_prior_run_contains_required_parts(tmp_path): + spec_file = tmp_path / "spec.md" + spec_file.write_text("spec content") + + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-120000", + {"branch": "my-branch", "branch_source": "new", "spec": str(spec_file)}, + sessions=[{"started_at": "2025-01-01T12:00:00Z"}], + ) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + result = render_original_spec(ctx) + + assert "## Original spec" in result + assert str(spec_file) in result + assert "250101-120000" in result + + +def test_render_original_spec_open_pr_contains_gh_pr_view(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + pr_url = "https://github.com/org/repo/pull/42" + ctx = _make_ctx( + branch_source="existing", branch="my-branch", project="myproject", pr_url=pr_url + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + result = render_original_spec(ctx) + + assert "## Original spec" in result + assert f"gh pr view {pr_url}" in result + assert f"PR: {pr_url}" in result + + +def test_render_original_spec_commits_only_contains_branch_and_git_log(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + ctx = _make_ctx( + branch_source="existing", + branch="my-branch", + project="myproject", + base_branch="origin/main", + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + result = render_original_spec(ctx) + + assert "## Original spec" in result + assert "my-branch" in result + assert "git log origin/main..HEAD" in result + + +def test_render_original_spec_commits_only_literal_commit_token(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + result = render_original_spec(ctx) + + assert "" in result + + +# --- preamble_label --- + + +def test_preamble_label_new_branch_returns_none(): + ctx = _make_ctx(branch_source="new") + assert preamble_label(ctx) is None + + +def test_preamble_label_commits_only_returns_none(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + assert preamble_label(ctx) is None + + +def test_preamble_label_prior_run_returns_run_id(tmp_path): + spec_file = tmp_path / "spec.md" + spec_file.write_text("spec") + + runs_dir = tmp_path / "runs" + _make_run( + runs_dir, + "myproject", + "250101-120000", + {"branch": "my-branch", "branch_source": "new", "spec": str(spec_file)}, + sessions=[{"started_at": "2025-01-01T12:00:00Z"}], + ) + + ctx = _make_ctx(branch_source="existing", branch="my-branch", project="myproject") + + with patch("draft.runs.runs_base", return_value=runs_dir): + label = preamble_label(ctx) + + assert label == "run 250101-120000" + + +def test_preamble_label_open_pr_returns_pr_url(tmp_path): + runs_dir = tmp_path / "runs" + (runs_dir / "myproject").mkdir(parents=True) + + pr_url = "https://github.com/org/repo/pull/42" + ctx = _make_ctx( + branch_source="existing", branch="my-branch", project="myproject", pr_url=pr_url + ) + + with patch("draft.runs.runs_base", return_value=runs_dir): + label = preamble_label(ctx) + + assert label == f"PR {pr_url}"