diff --git a/AGENTS.md b/AGENTS.md index ecb6dc2..4996ea9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,10 @@ Concretely: - `mise run lint` runs ruff, shellcheck, rumdl, actionlint, Vale, skillcheck, the helper-mode check, and the skill security scanner -- `mise run test` runs the behavioural test suite +- `mise run test` runs the behavioural test suite. Each test marks its steps + with `# Given`, `# When` and `# Then` comments, each followed by a short + phrase saying what that step sets up, does or checks; `# Given` is left out + when there is nothing to set up - The kit's helpers are POSIX `sh` and POSIX `awk`, held to it by ShellCheck's shebang dialect: no arrays, `[[ ]]`, `local` or `pipefail`. See [ADR-007](docs/decisions/ADR-007-posix-sh-helpers.md) for what replaces them. diff --git a/tests/unit/test_check_release.py b/tests/unit/test_check_release.py index 9b28f4c..18dda42 100644 --- a/tests/unit/test_check_release.py +++ b/tests/unit/test_check_release.py @@ -38,59 +38,101 @@ def run(root: Path, *args: str) -> subprocess.CompletedProcess[str]: def test_consistent_repository_passes(tmp_path: Path) -> None: + # Given a repository at 0.1.0 everywhere make_repo(tmp_path) - assert run(tmp_path, "v0.1.0").returncode == OK + + # When v0.1.0 is checked + result = run(tmp_path, "v0.1.0") + + # Then it passes + assert result.returncode == OK def test_notes_are_the_matching_section_only(tmp_path: Path) -> None: + # Given a changelog with a section before and after 0.1.0 make_repo(tmp_path) + + # When the notes for v0.1.0 are asked for result = run(tmp_path, "v0.1.0", "--notes") + + # Then only that section's body comes back assert result.returncode == OK assert result.stdout.strip() == "- First release.\n- Second line." @pytest.mark.parametrize("tag", ["0.1.0", "v1", "v0.1", "v0.1.0-rc1", ""]) def test_malformed_tag_is_a_usage_error(tmp_path: Path, tag: str) -> None: + # Given a consistent repository make_repo(tmp_path) - assert run(tmp_path, tag).returncode == USAGE + + # When a tag that is not vX.Y.Z is checked + result = run(tmp_path, tag) + + # Then it is a usage error + assert result.returncode == USAGE def test_skill_version_mismatch_fails(tmp_path: Path) -> None: + # Given a skill left at another version make_repo(tmp_path, skill="0.2.0") + + # When v0.1.0 is checked result = run(tmp_path, "v0.1.0") + + # Then it fails, naming the skill's version assert result.returncode == FAILED assert "SKILL.md has version '0.2.0'" in result.stderr def test_pyproject_version_mismatch_fails(tmp_path: Path) -> None: + # Given pyproject.toml left at another version make_repo(tmp_path, pyproject="0.0.9") + + # When v0.1.0 is checked result = run(tmp_path, "v0.1.0") + + # Then it fails, naming pyproject's version assert result.returncode == FAILED assert "pyproject.toml has version '0.0.9'" in result.stderr def test_missing_changelog_section_fails(tmp_path: Path) -> None: + # Given a changelog with no 0.2.0 section make_repo(tmp_path) + + # When v0.2.0 is checked result = run(tmp_path, "v0.2.0") + + # Then it fails on the missing section assert result.returncode == FAILED assert "no filled '## [0.2.0]' section" in result.stderr def test_every_problem_is_reported_in_one_run(tmp_path: Path) -> None: + # Given a skill and pyproject both at another version make_repo(tmp_path, skill="0.3.0", pyproject="0.3.0") + + # When v0.1.0 is checked result = run(tmp_path, "v0.1.0") + + # Then both problems are reported, not just the first assert result.stderr.count("ERROR:") == TWO_PROBLEMS def test_the_real_repository_agrees_with_its_own_version() -> None: """Skills, pyproject and changelog must move together, whatever the number.""" + # Given the version this repository's pyproject declares pyproject = (PROJECT_DIR / "pyproject.toml").read_text() match = re.search(r'^version = "(.+)"$', pyproject, re.MULTILINE) assert match + + # When that version's tag is checked against the repository itself result = subprocess.run( # noqa: S603 ["bash", str(SCRIPT), f"v{match.group(1)}"], # noqa: S607 capture_output=True, text=True, check=False, ) + + # Then it passes assert result.returncode == OK, result.stderr diff --git a/tests/unit/test_delivery_decompose_scripts.py b/tests/unit/test_delivery_decompose_scripts.py index 3dd1fb6..4ab9d1e 100644 --- a/tests/unit/test_delivery_decompose_scripts.py +++ b/tests/unit/test_delivery_decompose_scripts.py @@ -104,63 +104,100 @@ class TestRunAs: """The launcher reads the fields; the summary is what a human approved.""" def test_accepts_a_serial_set_whose_tasks_all_run_alone(self, tmp_path: Path) -> None: + # Given a serial set whose tasks all run alone tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", parallel="false") write_task(tasks, "ingestion-002", parallel="false") write_summary(tasks, run_as="serially") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID def test_rejects_a_serial_set_with_a_task_that_would_run_alongside(self, tmp_path: Path) -> None: """The summary says one at a time; the launcher would start two.""" + # Given a serial set with one task that may run alongside tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", parallel="false") write_task(tasks, "ingestion-002") write_summary(tasks, run_as="serially") + + # When the task set is validated result = validate(tasks) + + # Then that task is rejected assert result.returncode == INVALID assert "ingestion-002" in result.stdout assert "parallel must be false" in result.stdout def test_rejects_lanes_when_every_task_runs_alone(self, tmp_path: Path) -> None: + # Given a lanes set where every task runs alone tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", parallel="false") write_task(tasks, "ingestion-002", parallel="false") write_summary(tasks, run_as="lanes") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected as serial assert result.returncode == INVALID assert "should say serially" in result.stdout def test_accepts_lanes_with_one_task_that_runs_alone(self, tmp_path: Path) -> None: """A migration or lockfile change can run alone inside a set that otherwise parallelizes.""" + # Given a lanes set with one task that runs alone tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", parallel="false") write_task(tasks, "ingestion-002") write_summary(tasks, run_as="lanes") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID def test_rejects_an_undecided_run_as(self, tmp_path: Path) -> None: + # Given a summary whose Run as is still the template's choice tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_summary(tasks, run_as="lanes | serially") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "pick one" in result.stdout class TestValidTaskSets: def test_accepts_a_linear_dependency_chain(self, tmp_path: Path) -> None: + # Given a task depending on another tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_task(tasks, "ingestion-002", depends_on="ingestion-001") + + # When the task set is validated result = validate(tasks) + + # Then it is valid assert result.returncode == VALID assert "RESULT\tvalid" in result.stdout def test_reports_task_and_epic_counts(self, tmp_path: Path) -> None: + # Given two tasks in two epics tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_task(tasks, "api-001", epic="api") + + # When the task set is validated result = validate(tasks) + + # Then both counts are reported assert "TASKS\t2" in result.stdout assert "EPICS\t2" in result.stdout @@ -169,32 +206,52 @@ class TestRejectedTaskSets: """Each case here would stall one or more lanes if it reached a developer.""" def test_rejects_a_dependency_that_does_not_exist(self, tmp_path: Path) -> None: + # Given a dependency on a task that does not exist tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", depends_on="ingestion-404") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "unknown task" in result.stdout def test_rejects_a_dependency_cycle(self, tmp_path: Path) -> None: """A cycle leaves every task in it permanently blocked.""" + # Given two tasks depending on each other tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", depends_on="ingestion-002") write_task(tasks, "ingestion-002", depends_on="ingestion-001") + + # When the task set is validated result = validate(tasks) + + # Then the cycle is rejected assert result.returncode == INVALID assert "dependency cycle" in result.stdout def test_rejects_a_task_depending_on_itself(self, tmp_path: Path) -> None: + # Given a task depending on itself tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", depends_on="ingestion-001") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID def test_rejects_an_id_that_does_not_match_the_filename(self, tmp_path: Path) -> None: """The filename is how every other tool finds the task.""" + # Given a task whose id is not its filename tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", file_name="ingestion-002") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "does not match the filename" in result.stdout @@ -206,78 +263,133 @@ def test_accepts_every_documented_tier(self, tmp_path: Path, tier: str) -> None: teaching the validator fails here rather than at decomposition, after the epic has already been written. """ + # Given a task on a tier the conventions document tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", tier=tier) + + # When the task set is validated result = validate(tasks) + + # Then it is valid assert result.returncode == VALID, result.stdout def test_rejects_an_unknown_type(self, tmp_path: Path) -> None: """The type prefixes the lane's commit, so a typo reaches the history.""" + # Given a task with a type that is not conventional tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", task_type="feature") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "unknown type" in result.stdout def test_accepts_a_conventional_type(self, tmp_path: Path) -> None: + # Given a fix task tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", task_type="fix") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID @pytest.mark.parametrize("tier", ["gpt4", "Build"]) def test_rejects_an_unknown_tier(self, tmp_path: Path, tier: str) -> None: + # Given a task on an undocumented tier tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", tier=tier) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected, listing the tiers assert result.returncode == INVALID assert f"unknown tier: '{tier}' (expected plan, build, or chore)" in result.stdout def test_rejects_a_task_with_no_tier(self, tmp_path: Path) -> None: + # Given a task with no tier tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", tier=None) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "missing required field: tier" in result.stdout def test_rejects_a_task_without_acceptance_criteria(self, tmp_path: Path) -> None: """Without criteria there is nothing to check the work against.""" + # Given a task without acceptance criteria tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", criteria=False) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "Acceptance criteria" in result.stdout def test_accepts_an_epic_of_any_size(self, tmp_path: Path) -> None: """No count caps an epic: its size is the decomposer's judgement.""" + # Given an epic of 25 tasks tasks = tmp_path / "tasks" for n in range(1, 26): write_task(tasks, f"ingestion-{n:03d}") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID def test_rejects_a_file_without_frontmatter(self, tmp_path: Path) -> None: + # Given a task file without frontmatter tasks = tmp_path / "tasks" tasks.mkdir() (tasks / "ingestion-001.md").write_text("# Just a heading\n", encoding="utf-8") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "no frontmatter" in result.stdout def test_rejects_a_conflict_with_an_unknown_task(self, tmp_path: Path) -> None: + # Given a conflict with a task that does not exist tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", conflicts_with="ingestion-404") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID class TestValidatorUsage: def test_dies_on_a_missing_tasks_directory(self, tmp_path: Path) -> None: + # When the task set is validated result = validate(tmp_path / "absent") + + # Then it is a usage error assert result.returncode == USAGE_ERROR def test_dies_when_the_directory_holds_no_tasks(self, tmp_path: Path) -> None: + # Given an empty tasks directory empty = tmp_path / "tasks" empty.mkdir() + + # When the task set is validated result = validate(empty) + + # Then it is a usage error assert result.returncode == USAGE_ERROR @@ -291,37 +403,57 @@ class TestDecompositionSummary: def test_rejects_a_task_set_with_no_summary(self, tmp_path: Path) -> None: """Decomposition is the phase; the summary is its artifact.""" + # Given a task set with no decomposition summary tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") + + # When the task set is validated result = validate(tasks, "--decomposition", str(tmp_path / "absent.md")) + + # Then it is rejected assert result.returncode == INVALID assert "no decomposition summary" in result.stdout def test_rejects_a_task_missing_from_the_summary(self, tmp_path: Path) -> None: """A launcher would run it anyway, so nobody approved that lane.""" + # Given a summary that omits one task tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_task(tasks, "ingestion-002") write_summary(tasks, {"ingestion": ["ingestion-001"]}) + + # When the task set is validated result = validate(tasks) + + # Then that task is rejected assert result.returncode == INVALID assert "ingestion-002\tmissing from decomposition.md" in result.stdout def test_rejects_a_summary_listing_a_task_with_no_file(self, tmp_path: Path) -> None: """The lane can never start: there is nothing to launch it from.""" + # Given a summary listing a task with no file tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_summary(tasks, {"ingestion": ["ingestion-001", "ingestion-404"]}) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "lists a task with no file: 'ingestion-404'" in result.stdout def test_rejects_a_task_grouped_under_a_different_epic(self, tmp_path: Path) -> None: """The epic bounds the cap and names the slice, so the two must agree.""" + # Given a summary grouping a task under another epic tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001", epic="ingestion") write_summary(tasks, {"api": ["ingestion-001"]}) + + # When the task set is validated result = validate(tasks) + + # Then the mismatch is rejected assert result.returncode == INVALID assert "'ingestion' in the task file, 'api' in decomposition.md" in result.stdout @@ -331,6 +463,7 @@ def test_ignores_task_ids_outside_the_epics_section(self, tmp_path: Path) -> Non The critical path names the same ids in prose. Reading those as declarations would let a task drop out of Epics unnoticed. """ + # Given a summary naming an unknown id outside its Epics section tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") (tmp_path / "decomposition.md").write_text( @@ -341,15 +474,24 @@ def test_ignores_task_ids_outside_the_epics_section(self, tmp_path: Path) -> Non "## Critical path\n\n- `ingestion-404` - not a declaration\n", encoding="utf-8", ) + + # When the task set is validated result = validate(tasks) + + # Then it is valid assert result.returncode == VALID, result.stdout def test_accepts_a_summary_that_matches(self, tmp_path: Path) -> None: + # Given a summary matching the task set tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") write_task(tasks, "api-001", epic="api") write_summary(tasks) + + # When the task set is validated result = validate(tasks) + + # Then it is valid assert result.returncode == VALID, result.stdout @@ -398,51 +540,83 @@ class TestCovers: """A task names the requirements it covers, so what is left of each can be derived.""" def test_accepts_full_and_partial_covers_of_known_requirements(self, tmp_path: Path) -> None: + # Given a task covering one requirement whole and one in part tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "ingestion-001", covers="- A1\n- A4 (part): sign-in only;\n revocation comes later") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID def test_rejects_an_id_the_requirements_do_not_define(self, tmp_path: Path) -> None: + # Given a task covering an undefined requirement tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "ingestion-001", covers="- A9") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected, naming the PRD assert result.returncode == INVALID assert failures(result) == [ ["ingestion-001", "covers 'A9', which is no requirement id in " + str(tmp_path / "prd.md")] ] def test_rejects_a_partial_cover_without_a_note(self, tmp_path: Path) -> None: + # Given a partial cover with no note tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "ingestion-001", covers="- A4 (part):") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "covers A4 in part with no note" in failures(result)[0][1] def test_rejects_a_line_it_cannot_read(self, tmp_path: Path) -> None: + # Given a Covers line in no known form tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "ingestion-001", covers="- A4 partly, sign-in") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "unreadable Covers line 'A4 partly, sign-in'" in failures(result)[0][1] def test_rejects_covers_when_the_requirements_have_no_ids(self, tmp_path: Path) -> None: + # Given a task covering a requirement in a PRD without ids tasks = tmp_path / "tasks" tasks.mkdir() (tmp_path / "prd.md").write_text("# P\n\n## Outcomes\n\n- one server\n", encoding="utf-8") write_task(tasks, "ingestion-001", covers="- A1") + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert "has no ids" in failures(result)[0][1] def test_a_set_without_covers_needs_no_requirements(self, tmp_path: Path) -> None: """Covers are optional: the check costs nothing to a project that does not use them.""" + # Given a task set without covers and no PRD tasks = tmp_path / "tasks" write_task(tasks, "ingestion-001") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID class TestMilestones: @@ -462,20 +636,31 @@ def setup_two_milestones( ) def test_accepts_a_cut_of_the_current_milestone(self, tmp_path: Path) -> None: + # Given two milestones, with the first cut and fully covered tasks = tmp_path / "tasks" self.setup_two_milestones(tasks) + + # When the task set is validated result = validate(tasks) + + # Then it is valid, and the first is current assert result.returncode == VALID, result.stdout assert "MILESTONES\t2\tcurrent=skeleton" in result.stdout def test_rejects_a_target_no_task_of_the_milestone_covers(self, tmp_path: Path) -> None: + # Given a milestone target no task covers tasks = tmp_path / "tasks" self.setup_two_milestones(tasks, covers="- A1") + + # When the task set is validated result = validate(tasks) + + # Then the milestone is rejected assert result.returncode == INVALID assert failures(result) == [["skeleton", "targets A4, but no task of the milestone covers it"]] def test_rejects_tasks_of_a_milestone_after_the_current_one(self, tmp_path: Path) -> None: + # Given tasks cut for the second milestone before the first is reviewed tasks = tmp_path / "tasks" self.setup_two_milestones(tasks, second_epics="team") write_task(tasks, "team-001", epic="team", covers="- A4\n- X1a") @@ -486,7 +671,11 @@ def test_rejects_tasks_of_a_milestone_after_the_current_one(self, tmp_path: Path milestone("team", epics="team", targets="A4, X1a"), ), ) + + # When the task set is validated result = validate(tasks) + + # Then they are rejected assert result.returncode == INVALID assert failures(result) == [ [ @@ -499,6 +688,7 @@ def test_rejects_tasks_of_a_milestone_after_the_current_one(self, tmp_path: Path ] def test_an_approved_review_makes_the_next_milestone_current(self, tmp_path: Path) -> None: + # Given the first milestone's review approved, and the second cut tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1\n- A4 (part): sign-in") @@ -512,11 +702,16 @@ def test_an_approved_review_makes_the_next_milestone_current(self, tmp_path: Pat run_as="serially", ) review_note(tasks, "skeleton", "approved") + + # When the task set is validated result = validate(tasks) + + # Then it is valid, and the second is current assert result.returncode == VALID, result.stdout assert "current=team" in result.stdout def test_a_draft_review_does_not_close_a_milestone(self, tmp_path: Path) -> None: + # Given the second milestone cut while the first's review is a draft tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1\n- A4 (part): sign-in") @@ -529,10 +724,16 @@ def test_a_draft_review_does_not_close_a_milestone(self, tmp_path: Path) -> None ), ) review_note(tasks, "skeleton", "draft") - assert validate(tasks).returncode == INVALID + + # When the task set is validated + result = validate(tasks) + + # Then it is rejected + assert result.returncode == INVALID def test_run_as_describes_the_current_milestone_only(self, tmp_path: Path) -> None: """A closed milestone that ran in lanes does not stop the next from running serially.""" + # Given a closed milestone in lanes, and a serial current one tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") @@ -548,19 +749,30 @@ def test_run_as_describes_the_current_milestone_only(self, tmp_path: Path) -> No run_as="serially", ) review_note(tasks, "skeleton", "approved") - assert validate(tasks).returncode == VALID + + # When the task set is validated + result = validate(tasks) + + # Then it is valid + assert result.returncode == VALID def test_rejects_an_epic_in_no_milestone(self, tmp_path: Path) -> None: + # Given an epic no milestone lists tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") write_task(tasks, "ops-001", epic="ops") write_summary(tasks, milestones=plan(milestone("skeleton", epics="server", targets="A1"))) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert ["ops", "epic belongs to no milestone: list it on a milestone's **Epics:** line"] in failures(result) def test_rejects_an_epic_in_two_milestones(self, tmp_path: Path) -> None: + # Given an epic listed by two milestones tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") @@ -568,20 +780,30 @@ def test_rejects_an_epic_in_two_milestones(self, tmp_path: Path) -> None: tasks, milestones=plan(milestone("skeleton", epics="server", targets="A1"), milestone("team", epics="server")), ) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert ["server", "epic is in two milestones: skeleton and team"] in failures(result) def test_rejects_a_milestone_without_an_exit_criterion(self, tmp_path: Path) -> None: + # Given a milestone without an Exit line tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") write_summary(tasks, milestones=plan(milestone("skeleton", epics="server", targets="A1", exit_line=False))) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert result.returncode == INVALID assert failures(result)[0][0] == "skeleton" assert "no '**Exit:**' line" in failures(result)[0][1] def test_rejects_a_target_the_requirements_do_not_define(self, tmp_path: Path) -> None: + # Given a milestone targeting an undefined requirement tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") @@ -589,12 +811,17 @@ def test_rejects_a_target_the_requirements_do_not_define(self, tmp_path: Path) - tasks, milestones=plan(milestone("skeleton", epics="server", targets="A1"), milestone("later", targets="Z9")), ) + + # When the task set is validated result = validate(tasks) + + # Then it is rejected assert failures(result) == [ ["later", "targets 'Z9', which is no requirement id in " + str(tmp_path / "prd.md")] ] def test_a_wrapped_requirements_line_is_read_whole(self, tmp_path: Path) -> None: + # Given a Requirements line wrapped onto two lines tasks = tmp_path / "tasks" write_prd(tasks) write_task(tasks, "server-001", epic="server", covers="- A1") @@ -604,5 +831,9 @@ def test_a_wrapped_requirements_line_is_read_whole(self, tmp_path: Path) -> None "## Milestones\n\n### skeleton\n\n**Exit:** works.\n**Requirements:** A1,\n X1a\n**Epics:** server\n\n" ), ) + + # When the task set is validated result = validate(tasks) + + # Then the target on the second line is checked too assert failures(result) == [["skeleton", "targets X1a, but no task of the milestone covers it"]] diff --git a/tests/unit/test_delivery_implement_scripts.py b/tests/unit/test_delivery_implement_scripts.py index 2d9d191..8494eaa 100644 --- a/tests/unit/test_delivery_implement_scripts.py +++ b/tests/unit/test_delivery_implement_scripts.py @@ -170,5 +170,11 @@ def test_branch_samples_exclude_default_branch_names(self, tmp_path: Path) -> No def test_every_helper_script_is_present() -> None: """Guard against a script being renamed or removed without updating this suite.""" + # Given the helpers this suite covers expected = {"detect-repo-contract.sh"} - assert expected == {path.name for path in SCRIPTS_DIR.iterdir()} + + # When the scripts directory is listed + present = {path.name for path in SCRIPTS_DIR.iterdir()} + + # Then it holds exactly those + assert present == expected diff --git a/tests/unit/test_delivery_status_scripts.py b/tests/unit/test_delivery_status_scripts.py index 29057b7..989700c 100644 --- a/tests/unit/test_delivery_status_scripts.py +++ b/tests/unit/test_delivery_status_scripts.py @@ -108,52 +108,87 @@ class TestClaimValidation: """claim.sh - argument and environment checks before anything is pushed.""" def test_requires_a_task_id(self, tmp_path: Path, origin: Path) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a claim is made without an id result = run_claim([], cwd=dev) + + # Then it is an error assert result.returncode == CLAIM_ERROR @pytest.mark.parametrize("bad_id", ["Ingestion-003", "ingestion/003", "../escape", "ingestion 003"]) def test_rejects_ids_that_are_not_slugs(self, tmp_path: Path, origin: Path, bad_id: str) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a claim is made with an id that is not a slug result = run_claim([bad_id], cwd=dev) + + # Then it is rejected assert result.returncode == CLAIM_ERROR assert "invalid task id" in result.stderr def test_rejects_an_id_that_looks_like_an_option(self, tmp_path: Path, origin: Path) -> None: """A leading dash is refused as an unknown flag rather than silently claimed.""" + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a claim is made with an id starting with a dash result = run_claim(["-leading"], cwd=dev) + + # Then it is refused as an unknown option assert result.returncode == CLAIM_ERROR assert "unknown option" in result.stderr def test_a_dashed_id_can_still_be_passed_after_a_separator(self, tmp_path: Path, origin: Path) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a dashed id follows -- result = run_claim(["--", "-leading"], cwd=dev) + + # Then it reaches id validation assert result.returncode == CLAIM_ERROR assert "invalid task id" in result.stderr def test_dies_outside_a_git_repository(self, tmp_path: Path) -> None: + # Given a plain directory plain = tmp_path / "plain" plain.mkdir() + + # When a claim is made there result = run_claim(["ingestion-003"], cwd=plain) + + # Then it dies assert result.returncode == CLAIM_ERROR assert "not inside a git repository" in result.stderr def test_dies_when_the_remote_is_unknown(self, tmp_path: Path, origin: Path) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a claim names a remote it does not have result = run_claim(["--remote", "upstream", "ingestion-003"], cwd=dev) + + # Then it dies assert result.returncode == CLAIM_ERROR assert "is not configured" in result.stderr def test_dies_when_the_claimant_is_anonymous(self, tmp_path: Path, origin: Path) -> None: """A claim that cannot name its owner is worthless as a coordination record.""" + # Given a clone with no user.name anywhere dev = developer(tmp_path, origin, "devA") git(dev, "config", "--unset", "user.name") + + # When a claim is made result = run_claim( ["ingestion-003"], cwd=dev, extra_env={"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull}, ) + + # Then it dies, asking for user.name assert result.returncode == CLAIM_ERROR assert "user.name" in result.stderr @@ -162,8 +197,13 @@ class TestClaimProtocol: """claim.sh - the create-only branch push used as a distributed lock.""" def test_claims_a_free_task(self, tmp_path: Path, origin: Path) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") + + # When a free task is claimed result = run_claim(["ingestion-003"], cwd=dev) + + # Then the claim is won and its branch is on the remote assert result.returncode == CLAIM_WON rows = rows_of(result.stdout) assert first(rows, "CLAIMED") == "ingestion-003" @@ -178,53 +218,73 @@ def test_rejects_a_claim_on_a_held_task(self, tmp_path: Path, origin: Path) -> N A plain push of HEAD would be a no-op here and exit 0, handing the task to a second developer. The claim commit makes the push distinct, so it is rejected. """ + # Given two developers on the same commit, the first holding the task holder = developer(tmp_path, origin, "devA") latecomer = developer(tmp_path, origin, "devB") assert git(holder, "rev-parse", "HEAD").stdout == git(latecomer, "rev-parse", "HEAD").stdout assert run_claim(["ingestion-003"], cwd=holder).returncode == CLAIM_WON + + # When the second claims it result = run_claim(["ingestion-003"], cwd=latecomer) + # Then the claim is refused as held assert result.returncode == CLAIM_HELD rows = rows_of(result.stdout) assert first(rows, "HELD") == "ingestion-003" def test_names_the_holder_of_a_claimed_task(self, tmp_path: Path, origin: Path) -> None: + # Given a task held by devA holder = developer(tmp_path, origin, "devA") latecomer = developer(tmp_path, origin, "devB") run_claim(["ingestion-003"], cwd=holder) + # When devB claims it result = run_claim(["ingestion-003"], cwd=latecomer) + # Then the holder is named assert first(rows_of(result.stdout), "HOLDER") is not None assert "devA" in result.stdout def test_a_simultaneous_race_has_exactly_one_winner(self, tmp_path: Path, origin: Path) -> None: + # Given three developers devs = [developer(tmp_path, origin, f"dev{n}") for n in ("A", "B", "C")] def claim(dev: Path) -> int: return run_claim(["ingestion-003"], cwd=dev).returncode + # When all three claim the same task at once with ThreadPoolExecutor(max_workers=len(devs)) as pool: codes = list(pool.map(claim, devs)) + # Then exactly one wins, and the others see it held assert codes.count(CLAIM_WON) == 1, f"expected exactly one winner, got {codes}" assert all(code in (CLAIM_WON, CLAIM_HELD) for code in codes), f"unexpected failure among {codes}" def test_independent_tasks_do_not_contend(self, tmp_path: Path, origin: Path) -> None: + # Given a developer's clone dev = developer(tmp_path, origin, "devA") - assert run_claim(["ingestion-003"], cwd=dev).returncode == CLAIM_WON - assert run_claim(["api-001"], cwd=dev).returncode == CLAIM_WON + + # When two different tasks are claimed + first_claim = run_claim(["ingestion-003"], cwd=dev) + second_claim = run_claim(["api-001"], cwd=dev) + + # Then both are won + assert first_claim.returncode == CLAIM_WON + assert second_claim.returncode == CLAIM_WON def test_a_lost_claim_leaves_no_local_trace(self, tmp_path: Path, origin: Path) -> None: """Building the claim commit with commit-tree means a lost race needs no cleanup.""" + # Given a task held by devA holder = developer(tmp_path, origin, "devA") latecomer = developer(tmp_path, origin, "devB") run_claim(["ingestion-003"], cwd=holder) before = git(latecomer, "rev-parse", "HEAD").stdout + # When devB loses the claim run_claim(["ingestion-003"], cwd=latecomer) + # Then devB's checkout is untouched assert git(latecomer, "rev-parse", "HEAD").stdout == before assert git(latecomer, "status", "--porcelain").stdout == "" @@ -238,25 +298,36 @@ def _on_task_branch(self, tmp_path: Path, origin: Path, task_id: str) -> Path: return dev def test_advances_the_local_task_branch_to_the_claim_commit(self, tmp_path: Path, origin: Path) -> None: + # Given a clone on the task's branch dev = self._on_task_branch(tmp_path, origin, "ingestion-003") + + # When the task is claimed result = run_claim(["ingestion-003"], cwd=dev) + # Then the branch moves to the claim commit rows = rows_of(result.stdout) assert first(rows, "ADVANCED") == "task/ingestion-003" assert git(dev, "rev-parse", "HEAD").stdout.strip() == first(rows, "COMMIT") def test_a_plain_push_goes_to_the_claimed_branch(self, tmp_path: Path, origin: Path) -> None: """A branch started from the remote default branch tracks it until the claim says otherwise.""" + # Given a task branch tracking the remote default branch dev = developer(tmp_path, origin, "devA") git(dev, "checkout", "-q", "-b", "task/ingestion-003", "--track", "origin/main") + # When the task is claimed result = run_claim(["ingestion-003"], cwd=dev) + # Then the branch's upstream is the claimed branch assert first(rows_of(result.stdout), "UPSTREAM") == "origin/task/ingestion-003" + + # When work is committed and pushed with a plain git push (dev / "work.txt").write_text("done\n", encoding="utf-8") git(dev, "add", "work.txt") git(dev, "commit", "-q", "-m", "feat: work") git(dev, "push", "-q") + + # Then it lands on the task branch, not the default one assert ( git(dev, "ls-remote", "origin", "refs/heads/task/ingestion-003").stdout.split()[0] == git(dev, "rev-parse", "HEAD").stdout.strip() @@ -267,22 +338,28 @@ def test_a_plain_push_goes_to_the_claimed_branch(self, tmp_path: Path, origin: P ) def test_no_advance_leaves_the_local_branch_alone(self, tmp_path: Path, origin: Path) -> None: + # Given a clone on the task's branch dev = self._on_task_branch(tmp_path, origin, "ingestion-003") before = git(dev, "rev-parse", "HEAD").stdout + # When the task is claimed with --no-advance result = run_claim(["--no-advance", "ingestion-003"], cwd=dev) + # Then the branch has not moved assert result.returncode == CLAIM_WON assert git(dev, "rev-parse", "HEAD").stdout == before def test_a_dirty_worktree_is_never_reset(self, tmp_path: Path, origin: Path) -> None: """Advancing is a convenience; it must never discard uncommitted work.""" + # Given a clone on the task's branch with staged work dev = self._on_task_branch(tmp_path, origin, "ingestion-003") (dev / "scratch.txt").write_text("work in progress\n", encoding="utf-8") git(dev, "add", "scratch.txt") + # When the task is claimed result = run_claim(["ingestion-003"], cwd=dev) + # Then the branch is not advanced and the work is kept assert result.returncode == CLAIM_WON assert first(rows_of(result.stdout), "ADVANCED") is None assert (dev / "scratch.txt").read_text(encoding="utf-8") == "work in progress\n" @@ -384,70 +461,93 @@ class TestDerivedStatus: """status.sh - task state derived from git refs, with nothing stored.""" def test_an_unclaimed_task_without_dependencies_is_ready(self, tmp_path: Path, origin: Path) -> None: + # Given one task with no dependencies dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") + + # When status is read result = run_status(["--no-fetch"], cwd=dev) + + # Then it is ready assert task_rows(result.stdout)["ingestion-001"][1] == "ready" def test_a_claimed_task_is_in_progress_and_names_its_holder(self, tmp_path: Path, origin: Path) -> None: + # Given a task claimed by devA dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") run_claim(["ingestion-001"], cwd=dev) + # When status is read row = task_rows(run_status([], cwd=dev).stdout)["ingestion-001"] + # Then it is in progress, held by devA assert row[1] == "in-progress" assert row[2] == "devA" def test_a_task_waiting_on_an_unfinished_dependency_is_blocked(self, tmp_path: Path, origin: Path) -> None: + # Given a task depending on a ready one dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") + # When status is read rows = task_rows(run_status(["--no-fetch"], cwd=dev).stdout) + # Then the dependent is blocked assert rows["ingestion-001"][1] == "ready" assert rows["ingestion-002"][1] == "blocked" def test_claiming_a_dependency_does_not_unblock_its_dependents(self, tmp_path: Path, origin: Path) -> None: """Only merged work unblocks a dependent; work in progress is not done.""" + # Given a task depending on a claimed one dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") run_claim(["ingestion-001"], cwd=dev) + # When status is read rows = task_rows(run_status([], cwd=dev).stdout) + # Then the dependent is still blocked assert rows["ingestion-002"][1] == "blocked" def test_the_ready_filter_returns_only_ready_tasks(self, tmp_path: Path, origin: Path) -> None: + # Given a ready task and a blocked one dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") + # When status is read with --ready rows = task_rows(run_status(["--no-fetch", "--ready"], cwd=dev).stdout) + # Then only the ready one is listed assert list(rows) == ["ingestion-001"] def test_the_summary_counts_every_task(self, tmp_path: Path, origin: Path) -> None: + # Given one ready task and two blocked dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") write_task(dev, "ingestion-003", depends_on="ingestion-001") + # When status is read summary = next(row for row in rows_of(run_status(["--no-fetch"], cwd=dev).stdout) if row[0] == "SUMMARY") + # Then the summary counts them assert "ready=1" in summary assert "blocked=2" in summary def test_a_pull_request_out_of_draft_is_in_review(self, tmp_path: Path, origin: Path) -> None: """In review is what is waiting on a person rather than on a lane.""" + # Given a task with an open pull request out of draft dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") bin_dir = fake_gh(tmp_path, open_prs=[("ingestion-001", False)]) + # When status is read rows = task_rows(run_status(["--no-fetch"], cwd=dev, bin_dir=bin_dir).stdout) + # Then it is in review assert rows["ingestion-001"][1] == "in-review" def test_a_draft_pull_request_is_still_in_progress(self, tmp_path: Path, origin: Path) -> None: @@ -456,38 +556,48 @@ def test_a_draft_pull_request_is_still_in_progress(self, tmp_path: Path, origin: Counting a draft as in review would report every running lane as waiting on a reviewer, which is the opposite of the truth. """ + # Given a claimed task with a draft pull request dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") run_claim(["ingestion-001"], cwd=dev) bin_dir = fake_gh(tmp_path, open_prs=[("ingestion-001", True)]) + # When status is read rows = task_rows(run_status(["--no-fetch"], cwd=dev, bin_dir=bin_dir).stdout) + # Then it is in progress assert rows["ingestion-001"][1] == "in-progress" def test_a_merged_task_outranks_its_open_pull_request(self, tmp_path: Path, origin: Path) -> None: """Stale open pull requests exist; merged is the end state.""" + # Given a task both merged and with an open pull request dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") bin_dir = fake_gh(tmp_path, merged=["ingestion-001"], open_prs=[("ingestion-001", False)]) + # When status is read rows = task_rows(run_status(["--no-fetch"], cwd=dev, bin_dir=bin_dir).stdout) + # Then it is done assert rows["ingestion-001"][1] == "done" def test_a_task_merged_long_ago_with_its_branch_deleted_stays_done(self, tmp_path: Path, origin: Path) -> None: """Past the bulk query's window, with no branch left, it must not read as ready.""" + # Given a task merged outside the bulk query's window, and a dependent dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") bin_dir = fake_gh(tmp_path, merged_long_ago=["ingestion-001"]) + # When status is read rows = task_rows(run_status(["--no-fetch"], cwd=dev, bin_dir=bin_dir).stdout) + # Then it is done and its dependent ready assert rows["ingestion-001"][1] == "done" assert rows["ingestion-002"][1] == "ready" def test_dies_without_gh_rather_than_reporting_merged_work_as_ready(self, tmp_path: Path, origin: Path) -> None: + # Given a PATH with every tool but gh dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") no_gh = tmp_path / "no-gh" @@ -497,14 +607,17 @@ def test_dies_without_gh_rather_than_reporting_merged_work_as_ready(self, tmp_pa if tool.name != "gh" and not (no_gh / tool.name).exists(): (no_gh / tool.name).symlink_to(tool) + # When status is read result = run_status(["--no-fetch"], cwd=dev, path=str(no_gh)) + # Then it dies without printing any task assert result.returncode == CLAIM_ERROR assert "gh not found" in result.stderr assert "TASK" not in result.stdout def test_dies_when_gh_cannot_list_pull_requests(self, tmp_path: Path, origin: Path) -> None: """An unauthenticated gh must not read as "nothing is merged".""" + # Given a gh that always fails dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") bin_dir = tmp_path / "failing-gh" @@ -512,15 +625,23 @@ def test_dies_when_gh_cannot_list_pull_requests(self, tmp_path: Path, origin: Pa (bin_dir / "gh").write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") (bin_dir / "gh").chmod(0o755) + # When status is read result = run_status(["--no-fetch"], cwd=dev, bin_dir=bin_dir) + # Then it dies without printing any task assert result.returncode == CLAIM_ERROR assert "gh could not list pull requests" in result.stderr assert "TASK" not in result.stdout def test_dies_without_a_tasks_directory(self, tmp_path: Path, origin: Path) -> None: + # Given a clone with no tasks directory dev = developer(tmp_path, origin, "devA") - assert run_status(["--no-fetch"], cwd=dev).returncode == CLAIM_ERROR + + # When status is read + result = run_status(["--no-fetch"], cwd=dev) + + # Then it dies + assert result.returncode == CLAIM_ERROR def run_snapshot(args: list[str], *, cwd: Path, bin_dir: Path | None = None) -> subprocess.CompletedProcess[str]: @@ -567,24 +688,30 @@ class TestSnapshot: """snapshot.sh - the one document connectors to external tools read (ADR-005).""" def test_is_versioned_valid_json_naming_the_use_case(self, tmp_path: Path, origin: Path) -> None: + # Given one task dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") + # When a snapshot is taken snapshot = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev)) + # Then it is schema 1 JSON naming the repository and commit assert snapshot["schema"] == 1 assert snapshot["use_case"]["repo"] == "devA" assert snapshot["use_case"]["commit"] == git(dev, "rev-parse", "HEAD").stdout.strip() def test_tasks_carry_their_derived_state_and_frontmatter(self, tmp_path: Path, origin: Path) -> None: """The state must be the one status.sh derives, or a connector and a person disagree.""" + # Given a claimed task and one depending on it dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") write_task(dev, "ingestion-002", depends_on="ingestion-001") run_claim(["ingestion-001"], cwd=dev) + # When a snapshot is taken tasks = {t["id"]: t for t in snapshot_of(run_snapshot(["--no-fetch"], cwd=dev))["tasks"]} + # Then each task carries its derived state and its fields assert tasks["ingestion-001"]["state"] == "in-progress" assert tasks["ingestion-001"]["holder"] == "devA" assert tasks["ingestion-002"] == { @@ -601,6 +728,7 @@ def test_tasks_carry_their_derived_state_and_frontmatter(self, tmp_path: Path, o } def test_a_title_keeps_its_commas_and_loses_its_quotes(self, tmp_path: Path, origin: Path) -> None: + # Given a quoted title with commas dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") task_file = dev / "delivery" / "tasks" / "ingestion-001.md" @@ -611,28 +739,36 @@ def test_a_title_keeps_its_commas_and_loses_its_quotes(self, tmp_path: Path, ori encoding="utf-8", ) + # When a snapshot is taken task = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev))["tasks"][0] + # Then the title is unquoted and whole assert task["title"] == "Load HTML, Markdown and PDF" def test_a_task_under_review_links_its_pull_request(self, tmp_path: Path, origin: Path) -> None: + # Given a task with a pull request in review dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") url = "https://forge.example/org/devA/pull/42" bin_dir = fake_gh(tmp_path, open_prs=[("ingestion-001", False)], urls={"ingestion-001": url}) + # When a snapshot is taken task = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev, bin_dir=bin_dir))["tasks"][0] + # Then the task links that pull request assert task["state"] == "in-review" assert task["pull_request"] == url def test_epics_carry_what_they_deliver_from_the_decomposition(self, tmp_path: Path, origin: Path) -> None: + # Given a decomposition describing the epic over two lines, with quotes and a backslash dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") (dev / "delivery" / "decomposition.md").write_text(DECOMPOSITION, encoding="utf-8") + # When a snapshot is taken epics = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev))["epics"] + # Then the epic carries that text, joined and escaped assert epics == [ { "id": "ingestion", @@ -641,14 +777,18 @@ def test_epics_carry_what_they_deliver_from_the_decomposition(self, tmp_path: Pa ] def test_an_epic_missing_from_the_decomposition_still_appears(self, tmp_path: Path, origin: Path) -> None: + # Given a task whose epic no decomposition describes dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") + # When a snapshot is taken epics = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev))["epics"] + # Then the epic is listed without a description assert epics == [{"id": "ingestion", "delivers": None}] def test_lists_the_documents_a_reader_outside_git_needs(self, tmp_path: Path, origin: Path) -> None: + # Given a PRD, a decomposition, a contract and a decision record dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") delivery = dev / "delivery" @@ -659,8 +799,10 @@ def test_lists_the_documents_a_reader_outside_git_needs(self, tmp_path: Path, or (dev / "docs" / "adr").mkdir(parents=True) (dev / "docs" / "adr" / "ADR-001-storage.md").write_text("# ADR-001: Storage\n", encoding="utf-8") + # When a snapshot is taken documents = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev))["documents"] + # Then each is listed with its kind and title assert documents == [ {"kind": "requirements", "path": "delivery/prd.md", "title": "Shop requirements"}, {"kind": "decomposition", "path": "delivery/decomposition.md", "title": "Decomposition: shop"}, @@ -670,6 +812,7 @@ def test_lists_the_documents_a_reader_outside_git_needs(self, tmp_path: Path, or def test_fails_when_status_cannot_be_derived(self, tmp_path: Path, origin: Path) -> None: """A snapshot with guessed states would move Jira cards on a guess.""" + # Given a gh that always fails dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") bin_dir = tmp_path / "failing-gh" @@ -677,8 +820,10 @@ def test_fails_when_status_cannot_be_derived(self, tmp_path: Path, origin: Path) (bin_dir / "gh").write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") (bin_dir / "gh").chmod(0o755) + # When a snapshot is taken result = run_snapshot(["--no-fetch"], cwd=dev, bin_dir=bin_dir) + # Then it fails and prints nothing assert result.returncode == CLAIM_ERROR assert result.stdout == "" @@ -757,23 +902,35 @@ class TestMilestoneState: """status.sh - a milestone's state, derived from its tasks and its review note.""" def test_a_plan_without_milestones_prints_no_milestone_row(self, tmp_path: Path, origin: Path) -> None: + # Given a decomposition without milestones dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") (dev / "delivery" / "decomposition.md").write_text(DECOMPOSITION, encoding="utf-8") - assert milestone_rows(run_status(["--no-fetch"], cwd=dev).stdout) == [] + + # When status is read + result = run_status(["--no-fetch"], cwd=dev) + + # Then no milestone row is printed + assert milestone_rows(result.stdout) == [] def test_the_current_milestone_is_active_until_its_tasks_merge(self, tmp_path: Path, origin: Path) -> None: + # Given the first milestone with one of its two tasks merged dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") write_covering_task(dev, "server-002", "- R1") + + # When status is read result = run_status(["--no-fetch"], cwd=dev, bin_dir=fake_gh(tmp_path, merged=["server-001"])) + + # Then it is active and the next is planned assert milestone_rows(result.stdout) == [ ["skeleton", "active", "server", "A1 A4 R1"], ["team", "planned", "-", "A4 X1a"], ] def test_merged_then_reviewing_then_closed(self, tmp_path: Path, origin: Path) -> None: + # Given every task of the first milestone merged dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") @@ -783,17 +940,32 @@ def test_merged_then_reviewing_then_closed(self, tmp_path: Path, origin: Path) - def states() -> list[str]: return [row[1] for row in milestone_rows(run_status(["--no-fetch"], cwd=dev, bin_dir=gh).stdout)] + # Then, before any review note, it is merged assert states() == ["merged", "planned"] + + # When a draft review note is written write_review(dev, "skeleton", "draft") + + # Then it is reviewing assert states() == ["reviewing", "planned"] + + # When the review note is approved write_review(dev, "skeleton", "approved") + + # Then it is closed, and the next awaits its cut assert states() == ["closed", "undecomposed"] def test_a_filter_prints_task_rows_only(self, tmp_path: Path, origin: Path) -> None: + # Given a plan with milestones dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") - assert milestone_rows(run_status(["--no-fetch", "--ready"], cwd=dev).stdout) == [] + + # When status is read with --ready + result = run_status(["--no-fetch", "--ready"], cwd=dev) + + # Then no milestone row is printed + assert milestone_rows(result.stdout) == [] def run_coverage(args: list[str], *, cwd: Path, bin_dir: Path) -> subprocess.CompletedProcess[str]: @@ -813,11 +985,16 @@ class TestCoverage: """coverage.sh - what merged work covers of each requirement.""" def test_reports_each_requirement_with_the_tasks_behind_it(self, tmp_path: Path, origin: Path) -> None: + # Given tasks covering A1, part of A4 and R1, with only server-001 merged dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1\n- A4 (part): sign-in only;\n revocation comes with team") write_covering_task(dev, "server-002", "- R1") + + # When coverage is read result = run_coverage([], cwd=dev, bin_dir=fake_gh(tmp_path, merged=["server-001"])) + + # Then each requirement has its state, milestones and tasks assert result.returncode == 0, result.stderr assert rows_of(result.stdout) == [ ["REQ", "A1", "covered", "skeleton", "server-001", "-"], @@ -829,38 +1006,59 @@ def test_reports_each_requirement_with_the_tasks_behind_it(self, tmp_path: Path, ] def test_a_full_cover_merged_later_completes_a_partial_one(self, tmp_path: Path, origin: Path) -> None: + # Given a partial cover of A4 and a full one, both merged dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A4 (part): sign-in only") write_covering_task(dev, "server-002", "- A4") + + # When coverage is read result = run_coverage([], cwd=dev, bin_dir=fake_gh(tmp_path, merged=["server-001", "server-002"])) + + # Then A4 is covered with no part left assert ["REQ", "A4", "covered", "skeleton,team", "server-001,server-002", "-"] in rows_of(result.stdout) assert [row for row in rows_of(result.stdout) if row[0] == "PART"] == [] def test_a_milestone_narrows_the_report_to_its_targets(self, tmp_path: Path, origin: Path) -> None: + # Given the team milestone targeting A4 and X1a dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") + + # When coverage is read for team result = run_coverage(["--milestone", "team"], cwd=dev, bin_dir=fake_gh(tmp_path)) + + # Then only its targets are reported assert [row[1] for row in rows_of(result.stdout) if row[0] == "REQ"] == ["A4", "X1a"] def test_an_unknown_milestone_is_an_error(self, tmp_path: Path, origin: Path) -> None: + # Given a plan with milestones dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") + + # When coverage is read for a milestone it lacks result = run_coverage(["--milestone", "nope"], cwd=dev, bin_dir=fake_gh(tmp_path)) + + # Then it is an error assert result.returncode == 1 assert "no milestone 'nope'" in result.stderr def test_requirements_without_ids_are_an_error(self, tmp_path: Path, origin: Path) -> None: + # Given a PRD without requirement ids dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") (dev / "delivery" / "prd.md").write_text("# P\n\n- one server\n", encoding="utf-8") + + # When coverage is read result = run_coverage([], cwd=dev, bin_dir=fake_gh(tmp_path)) + + # Then it is an error assert result.returncode == 1 assert "has no requirement ids" in result.stderr def test_fails_rather_than_guess_when_merged_state_cannot_be_read(self, tmp_path: Path, origin: Path) -> None: + # Given a gh that always fails dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1") @@ -868,7 +1066,11 @@ def test_fails_rather_than_guess_when_merged_state_cannot_be_read(self, tmp_path bin_dir.mkdir() (bin_dir / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") (bin_dir / "gh").chmod(0o755) + + # When coverage is read result = run_coverage([], cwd=dev, bin_dir=bin_dir) + + # Then it fails without printing requirements assert result.returncode == 1 assert "REQ" not in result.stdout @@ -877,13 +1079,16 @@ class TestSnapshotMilestones: """snapshot.sh - milestones and requirement coverage, for connectors.""" def test_carries_milestones_and_coverage(self, tmp_path: Path, origin: Path) -> None: + # Given a plan with milestones, and server-001 merged dev = developer(tmp_path, origin, "devA") write_plan(dev) write_covering_task(dev, "server-001", "- A1\n- A4 (part): sign-in only") write_covering_task(dev, "server-002", "- R1") + # When a snapshot is taken snapshot = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev, bin_dir=fake_gh(tmp_path, merged=["server-001"]))) + # Then it carries each milestone and each requirement's coverage assert snapshot["schema"] == 1 assert snapshot["milestones"] == [ { @@ -930,11 +1135,14 @@ def test_carries_milestones_and_coverage(self, tmp_path: Path, origin: Path) -> ] def test_a_project_without_them_has_empty_lists(self, tmp_path: Path, origin: Path) -> None: + # Given a project without milestones or requirement ids dev = developer(tmp_path, origin, "devA") write_task(dev, "ingestion-001") (dev / "delivery" / "prd.md").write_text("# Shop requirements\n\n- loads documents\n", encoding="utf-8") + # When a snapshot is taken snapshot = snapshot_of(run_snapshot(["--no-fetch"], cwd=dev)) + # Then both lists are empty assert snapshot["milestones"] == [] assert snapshot["requirements"] == [] diff --git a/tests/unit/test_detect_ai_assist.py b/tests/unit/test_detect_ai_assist.py index fba6e8e..58707a9 100644 --- a/tests/unit/test_detect_ai_assist.py +++ b/tests/unit/test_detect_ai_assist.py @@ -34,36 +34,72 @@ def detect(commits: str = "", *, author: str = "someone") -> subprocess.Complete ], ) def test_an_agent_trailer_counts(trailer: str) -> None: - result = detect(f"fix: a thing\n\nBody text.\n\n{trailer}\n") + # Given a commit ending with an agent's trailer + commits = f"fix: a thing\n\nBody text.\n\n{trailer}\n" + + # When the detector reads it + result = detect(commits) + + # Then the pull request is assisted, because of the trailer assert result.returncode == YES assert "trailer" in result.stdout def test_any_commit_of_several_can_carry_the_trailer() -> None: + # Given three commits, only the second with an agent trailer commits = "feat: one\n\nplain\nfix: two\n\nCo-Authored-By: Claude \nchore: three\n" - assert detect(commits).returncode == YES + + # When the detector reads them + result = detect(commits) + + # Then the pull request is assisted + assert result.returncode == YES def test_a_human_co_author_does_not_count() -> None: - assert detect("fix: it\n\nCo-authored-by: Sam Doe \n").returncode == NO + # Given a commit co-authored by a person + commits = "fix: it\n\nCo-authored-by: Sam Doe \n" + + # When the detector reads it + result = detect(commits) + + # Then the pull request is not assisted + assert result.returncode == NO def test_merely_mentioning_a_tool_is_not_a_signal() -> None: + # Given a commit that names agents in its text, not in a trailer commits = "docs: compare with Claude Code and Copilot CLI\n\nCodex is also supported.\n" - assert detect(commits).returncode == NO + + # When the detector reads it + result = detect(commits) + + # Then the pull request is not assisted + assert result.returncode == NO def test_no_commits_and_nothing_else_is_not_assisted() -> None: - assert detect().returncode == NO + # When the detector reads no commits from a human author + result = detect() + + # Then the pull request is not assisted + assert result.returncode == NO @pytest.mark.parametrize("author", ["Copilot", "copilot-swe-agent[bot]", "app/copilot-swe-agent", "claude[bot]"]) def test_an_agents_own_account_counts(author: str) -> None: + # When an agent's account opened the pull request result = detect("fix: it\n", author=author) + + # Then it is assisted, because of the account assert result.returncode == YES assert "account" in result.stdout @pytest.mark.parametrize("author", ["dependabot[bot]", "ancelin-code", "github-actions[bot]"]) def test_other_accounts_do_not_count(author: str) -> None: - assert detect("fix: it\n", author=author).returncode == NO + # When a person or a non-agent bot opened the pull request + result = detect("fix: it\n", author=author) + + # Then it is not assisted + assert result.returncode == NO diff --git a/tests/unit/test_follow_script.py b/tests/unit/test_follow_script.py index 08c3bc3..45f0ff8 100644 --- a/tests/unit/test_follow_script.py +++ b/tests/unit/test_follow_script.py @@ -95,6 +95,7 @@ def launched(tmp_path: Path) -> list[str]: class TestAdvancing: def test_a_merge_launches_what_it_made_ready(self, tmp_path: Path) -> None: """The point of the loop: nobody has to ask what is next.""" + # Given a-002 waits on a-001, which merges on the second poll repo = repo_with(tmp_path, {"a-001": "", "a-002": ""}) status = scripted_status( tmp_path, @@ -104,35 +105,59 @@ def test_a_merge_launches_what_it_made_ready(self, tmp_path: Path) -> None: {"a-001": "done", "a-002": "done"}, ], ) + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it reports the merge, launches a-002, and completes assert result.returncode == COMPLETE, result.stdout + result.stderr assert ["MERGED", "a-001"] in events(result.stdout) assert launched(tmp_path) == ["a-002"] assert events(result.stdout)[-1] == ["COMPLETE"] def test_already_done_work_is_not_announced_again(self, tmp_path: Path) -> None: + # Given a task already done on the first poll repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status(tmp_path, [{"a-001": "done"}]) + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it only reports completion assert events(result.stdout) == [["COMPLETE"]] def test_stuck_when_nothing_can_unblock_the_rest(self, tmp_path: Path) -> None: + # Given a blocked task and nothing in flight repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status(tmp_path, [{"a-001": "blocked"}]) + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it stops as stuck, naming the task assert result.returncode == STUCK assert events(result.stdout) == [["STUCK", "a-001"]] def test_no_launch_only_reports(self, tmp_path: Path) -> None: + # Given a ready task repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status(tmp_path, [{"a-001": "ready"}]) + + # When the loop follows without launching follow(repo, status, recording_launcher(tmp_path), "--no-launch", "--max-polls", "2") + + # Then nothing is launched assert launched(tmp_path) == [] def test_launcher_options_pass_through(self, tmp_path: Path) -> None: + # Given a ready task repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status(tmp_path, [{"a-001": "ready"}]) + + # When the loop follows with a launcher option after -- follow(repo, status, recording_launcher(tmp_path), "--max-polls", "1", "--", "--no-web") + + # Then the launcher receives it, with the lane limit args = (tmp_path / "launches.log").read_text() assert "--no-web" in args assert "--max-lanes 3" in args @@ -142,86 +167,131 @@ class TestMilestoneBoundary: """The loop never crosses into the next milestone; it says a person is needed.""" def test_a_merged_milestone_stops_the_loop_for_its_review(self, tmp_path: Path) -> None: + # Given a milestone whose last task merges, and another planned repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status( tmp_path, [{"a-001": "in-progress"}, {"a-001": "done"}], milestones="MILESTONE\tskeleton\tmerged\ta\t-\nMILESTONE\tteam\tplanned\t-\t-\n", ) + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it stops at the merged milestone, not at completion assert result.returncode == COMPLETE assert events(result.stdout)[-1] == ["MILESTONE", "skeleton", "merged"] assert ["COMPLETE"] not in events(result.stdout) def test_an_uncut_milestone_is_reported_rather_than_started(self, tmp_path: Path) -> None: + # Given the next milestone has no tasks yet repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status( tmp_path, [{"a-001": "done"}], milestones="MILESTONE\tskeleton\tclosed\ta\t-\nMILESTONE\tteam\tundecomposed\t-\t-\n", ) + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it reports the milestone and launches nothing assert events(result.stdout) == [["MILESTONE", "team", "undecomposed"]] assert launched(tmp_path) == [] def test_every_milestone_closed_is_complete(self, tmp_path: Path) -> None: + # Given the only milestone is closed repo = repo_with(tmp_path, {"a-001": ""}) status = scripted_status(tmp_path, [{"a-001": "done"}], milestones="MILESTONE\tskeleton\tclosed\ta\t-\n") + + # When the loop follows the set result = follow(repo, status, recording_launcher(tmp_path)) + + # Then it completes assert events(result.stdout) == [["COMPLETE"]] class TestLimits: def test_never_launches_what_conflicts_with_work_in_flight(self, tmp_path: Path) -> None: """The launcher only checks conflicts within its own batch.""" + # Given a-002 conflicts with a-001, which is in flight repo = repo_with(tmp_path, {"a-001": "", "a-002": "a-001", "a-003": ""}) status = scripted_status(tmp_path, [{"a-001": "in-progress", "a-002": "ready", "a-003": "ready"}]) + + # When the loop polls once result = follow(repo, status, recording_launcher(tmp_path), "--max-polls", "1") + + # Then only a-003 launches, and a-002 is skipped with the reason assert launched(tmp_path) == ["a-003"] assert ["SKIP", "a-002", "conflicts with a-001, in flight"] in events(result.stdout) def test_lanes_running_here_use_up_slots(self, tmp_path: Path) -> None: + # Given one lane already running from this checkout repo = repo_with(tmp_path, {"a-001": "", "a-002": ""}) logs = repo / ".lanes" / "logs" logs.mkdir(parents=True) (logs / "a-001.log").write_text("working\n", encoding="utf-8") status = scripted_status(tmp_path, [{"a-001": "in-progress", "a-002": "ready"}]) + + # When the loop polls once with two slots follow(repo, status, recording_launcher(tmp_path), "--max-polls", "1", "--max-lanes", "2") + + # Then the launcher is offered the one slot left assert "--max-lanes 1" in (tmp_path / "launches.log").read_text() def test_no_launch_when_every_slot_is_taken(self, tmp_path: Path) -> None: + # Given one lane already running from this checkout repo = repo_with(tmp_path, {"a-001": "", "a-002": ""}) logs = repo / ".lanes" / "logs" logs.mkdir(parents=True) (logs / "a-001.log").write_text("working\n", encoding="utf-8") status = scripted_status(tmp_path, [{"a-001": "in-progress", "a-002": "ready"}]) + + # When the loop polls once with one slot follow(repo, status, recording_launcher(tmp_path), "--max-polls", "1", "--max-lanes", "1") + + # Then nothing is launched assert launched(tmp_path) == [] class TestLaneOutcomes: def test_a_published_lane_is_announced_once(self, tmp_path: Path) -> None: + # Given a lane log that records a published pull request repo = repo_with(tmp_path, {"a-001": ""}) logs = repo / ".lanes" / "logs" logs.mkdir(parents=True) (logs / "a-001.log").write_text("done\nPUBLISHED\ta-001\thttps://example.test/pr/4\n", encoding="utf-8") status = scripted_status(tmp_path, [{"a-001": "in-progress"}]) + + # When the loop polls three times result = follow(repo, status, recording_launcher(tmp_path), "--max-polls", "3") + + # Then the pull request is announced once assert events(result.stdout).count(["PUBLISHED", "a-001", "https://example.test/pr/4"]) == 1 def test_a_lane_that_ended_without_a_pull_request_is_reported(self, tmp_path: Path) -> None: + # Given a lane log that ended with the lane kept repo = repo_with(tmp_path, {"a-001": ""}) logs = repo / ".lanes" / "logs" logs.mkdir(parents=True) (logs / "a-001.log").write_text("KEPT\ta-001\tthe worktree has changes\n", encoding="utf-8") status = scripted_status(tmp_path, [{"a-001": "in-progress"}]) + + # When the loop polls once result = follow(repo, status, recording_launcher(tmp_path), "--max-polls", "1") + + # Then the lane is reported as failed assert events(result.stdout)[0][:2] == ["LANE-FAILED", "a-001"] def test_unreadable_state_is_retried_then_given_up(self, tmp_path: Path) -> None: + # Given a status script that always fails repo = repo_with(tmp_path, {"a-001": ""}) broken = tmp_path / "status.sh" broken.write_text("exit 1\n", encoding="utf-8") + + # When the loop follows result = follow(repo, broken, recording_launcher(tmp_path)) + + # Then it reports five errors and gives up assert result.returncode == 1 assert [e[0] for e in events(result.stdout)] == ["ERROR"] * 5 diff --git a/tests/unit/test_harness_adapters.py b/tests/unit/test_harness_adapters.py index bc80a31..fde4fc6 100644 --- a/tests/unit/test_harness_adapters.py +++ b/tests/unit/test_harness_adapters.py @@ -113,7 +113,10 @@ def skill_path() -> str: class TestCommandLines: def test_claude(self, tmp_path: Path) -> None: + # When a lane launches on claude args, _ = run_lane(tmp_path, "claude") + + # Then it gets the slash prompt, the model and its tool rules assert args == [ "-p", SLASH_PROMPT, "--model", "m-1", @@ -123,7 +126,10 @@ def test_claude(self, tmp_path: Path) -> None: ] # fmt: skip def test_copilot(self, tmp_path: Path) -> None: + # When a lane launches on copilot args, _ = run_lane(tmp_path, "copilot") + + # Then it gets the slash prompt, the model and its tool rules assert args == [ "-p", SLASH_PROMPT, "--model", "m-1", @@ -133,70 +139,106 @@ def test_copilot(self, tmp_path: Path) -> None: ] # fmt: skip def test_codex(self, tmp_path: Path) -> None: + # When a lane launches on codex args, _ = run_lane(tmp_path, "codex") + + # Then it runs exec in a workspace sandbox, with a prompt that reads the skill assert args[:5] == ["exec", "--sandbox", "workspace-write", "--model", "m-1"] assert args[5].startswith("Read ") assert args[5].endswith("with the arguments: " + TASK_ARGS) assert len(args) == 6 # noqa: PLR2004 def test_opencode(self, tmp_path: Path) -> None: + # When a lane launches on opencode args, other = run_lane(tmp_path, "opencode") + + # Then its permissions travel in the config, not the arguments assert args[:4] == ["run", "--auto", "--model", "m-1"] assert args[4].startswith("Read ") assert len(args) == 5 # noqa: PLR2004 assert json.loads(other["CONFIG"]) == {"permission": {"bash": DENY_TERRAFORM}} def test_pi(self, tmp_path: Path) -> None: + # When a lane launches on pi args, _ = run_lane(tmp_path, "pi") + + # Then it gets a prompt that reads the skill, then the model assert args[0] == "-p" assert args[1].startswith("Read ") assert args[2:] == ["--model", "m-1"] @pytest.mark.parametrize("harness", ["claude", "copilot", "codex", "opencode", "pi"]) def test_no_model_flag_without_a_mapping(self, tmp_path: Path, harness: str) -> None: + # When a lane launches with no model mapped args, _ = run_lane(tmp_path, harness, model=None) + + # Then no model flag is passed assert "--model" not in args def test_claude_passes_the_advisor(self, tmp_path: Path) -> None: + # When a claude lane launches with an advisor mapped args, _ = run_lane(tmp_path, "claude", advisor="a-1") + + # Then the advisor follows the model assert args[2:6] == ["--model", "m-1", "--advisor", "a-1"] @pytest.mark.parametrize("harness", ["copilot", "codex", "opencode", "pi"]) def test_only_claude_has_an_advisor(self, tmp_path: Path, harness: str) -> None: + # When another harness launches with an advisor mapped for any harness args, _ = run_lane(tmp_path, harness, advisor="a-1", mapped_for="") + + # Then the advisor is dropped assert "a-1" not in args def test_an_advisor_mapped_for_another_harness_is_not_passed(self, tmp_path: Path) -> None: + # When a claude lane launches on a tier mapped for copilot args, _ = run_lane(tmp_path, "claude", advisor="a-1", mapped_for="copilot") + + # Then neither the model nor the advisor is passed assert "--advisor" not in args assert "--model" not in args def test_the_lane_runs_in_its_worktree(self, tmp_path: Path) -> None: + # When a lane launches _, other = run_lane(tmp_path, "pi") + + # Then the harness runs in the lane's worktree assert other["PWD"] == str(tmp_path / "lanes" / "b-001") class TestWebAccess: def test_opencode_denies_web_fetch_when_asked(self, tmp_path: Path) -> None: + # When an opencode lane launches without web access _, other = run_lane(tmp_path, "opencode", "--no-web") + + # Then web fetch is denied in its config assert json.loads(other["CONFIG"]) == {"permission": {"bash": DENY_TERRAFORM, "webfetch": "deny"}} def test_codex_and_pi_have_no_web_tool_to_take_away(self, tmp_path: Path) -> None: + # When a codex lane launches with and without web access on, _ = run_lane(tmp_path / "on", "codex") off, _ = run_lane(tmp_path / "off", "codex", "--no-web") + + # Then the command line is the same assert on == off class TestCustomCommand: def test_placeholders_become_the_lanes_values(self, tmp_path: Path) -> None: + # Given a custom command that logs its placeholders template = 'for v in {model} {worktree} {plan_dir}; do printf "VALUE %s\\n" "$v" >> "$FAKE_LOG"; done' + + # When a lane launches on it run_lane(tmp_path, "custom", env={"DELIVERY_LANE_CMD": template}) + + # Then each placeholder became the lane's value wait_for(lambda: (tmp_path / "agent.log").read_text(encoding="utf-8").count("VALUE ") >= 3) # noqa: PLR2004 log = (tmp_path / "agent.log").read_text(encoding="utf-8").splitlines() values = [line.removeprefix("VALUE ") for line in log if line.startswith("VALUE ")] assert values == ["m-1", str(tmp_path / "lanes" / "b-001"), str(tmp_path / "devA")] def test_a_hostile_path_is_a_value_and_never_code(self, tmp_path: Path) -> None: + # Given a worktree root whose name holds a command substitution marker = tmp_path / "pwned" template = 'printf "VALUE %s\\n" {worktree} >> "$FAKE_LOG"' root = tmp_path / f"x $(touch {marker}) y" @@ -204,6 +246,8 @@ def test_a_hostile_path_is_a_value_and_never_code(self, tmp_path: Path) -> None: dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, ("claude",)) + + # When a custom lane launches under it result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(root), "--harness", "custom", "a-001"], @@ -212,16 +256,21 @@ def test_a_hostile_path_is_a_value_and_never_code(self, tmp_path: Path) -> None: isolated=True, env_extra={"DELIVERY_LANE_CMD": template}, ) + + # Then the path is passed as text and never run assert "LAUNCHED\ta-001\tcustom" in result.stdout, result.stdout + result.stderr wait_for(lambda: "VALUE" in log.read_text(encoding="utf-8")) assert not marker.exists() assert str(root / "a-001") in log.read_text(encoding="utf-8") def test_it_needs_a_command(self, tmp_path: Path) -> None: + # Given no DELIVERY_LANE_CMD origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + + # When a custom lane is launched result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "--harness", "custom", "a-001"], @@ -229,6 +278,8 @@ def test_it_needs_a_command(self, tmp_path: Path) -> None: log, isolated=True, ) + + # Then it is skipped, saying what is missing assert "SKIP\ta-001\tcustom needs DELIVERY_LANE_CMD to be set" in result.stdout @@ -245,8 +296,11 @@ class TestPrompt: ], ) def test_names_the_skill_it_is_given(self, harness: str, cross: str, expected: str) -> None: + # Given the delivery-refine skill skill = str(SKILLS / "delivery-orchestrator" / "scripts" / ".." / ".." / "delivery-refine" / "SKILL.md") lib = SKILLS / "delivery-orchestrator" / "scripts" / "harness.sh" + + # When a prompt is built for it result = subprocess.run( # noqa: S603 [ SH, @@ -262,4 +316,6 @@ def test_names_the_skill_it_is_given(self, harness: str, cross: str, expected: s text=True, check=True, ) + + # Then the prompt names that skill, not delivery-implement assert result.stdout == expected.format(skill=skill) diff --git a/tests/unit/test_launch_lanes_script.py b/tests/unit/test_launch_lanes_script.py index 2153dd5..7641ac4 100644 --- a/tests/unit/test_launch_lanes_script.py +++ b/tests/unit/test_launch_lanes_script.py @@ -131,71 +131,117 @@ def launched_ids(stdout: str) -> list[str]: class TestLaneSelection: def test_launches_the_tasks_it_is_given(self, tmp_path: Path) -> None: + # Given two tasks repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002") + + # When both are planned result = launch(repo, ["--dry-run", "a-001", "a-002"]) + + # Then both would launch assert result.returncode == OK assert launched_ids(result.stdout) == ["a-001", "a-002"] def test_respects_the_lane_cap(self, tmp_path: Path) -> None: """Readiness is not capacity: more ready tasks than lanes is the normal case.""" + # Given five tasks and a cap of two repo = make_repo(tmp_path) for n in range(1, 6): add_task(repo, f"a-00{n}") cap = 2 + + # When they are planned result = launch(repo, ["--dry-run", "--max-lanes", str(cap), *[f"a-00{n}" for n in range(1, 6)]]) + + # Then two would launch assert len(launched_ids(result.stdout)) == cap assert f"LANES\t{cap}" in result.stdout def test_never_runs_two_conflicting_tasks_together(self, tmp_path: Path) -> None: + # Given a-002 conflicting with a-001 repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002", conflicts_with="a-001") + + # When both are planned result = launch(repo, ["--dry-run", "a-001", "a-002"]) + + # Then only a-001 would launch assert launched_ids(result.stdout) == ["a-001"] assert "conflicts with running lane a-001" in result.stdout def test_catches_a_conflict_declared_on_only_one_side(self, tmp_path: Path) -> None: """A one-sided declaration must still hold when the tasks arrive reversed.""" + # Given a conflict declared only on a-002 repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002", conflicts_with="a-001") + + # When they are planned in reverse order result = launch(repo, ["--dry-run", "a-002", "a-001"]) + + # Then only a-002 would launch assert launched_ids(result.stdout) == ["a-002"] assert "conflicts with running lane a-002" in result.stdout def test_a_conflicting_task_does_not_consume_a_lane_slot(self, tmp_path: Path) -> None: + # Given a-002 conflicting with a-001, and a cap of two repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002", conflicts_with="a-001") add_task(repo, "a-003") + + # When three tasks are planned result = launch(repo, ["--dry-run", "--max-lanes", "2", "a-001", "a-002", "a-003"]) + + # Then the skipped one leaves its slot to a-003 assert launched_ids(result.stdout) == ["a-001", "a-003"] def test_skips_a_task_with_no_file(self, tmp_path: Path) -> None: + # Given no file for a-999 repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a-999 is planned result = launch(repo, ["--dry-run", "a-999"]) + + # Then it is skipped and nothing launches assert result.returncode == NO_LANES assert "no task file" in result.stdout def test_dies_when_the_tasks_directory_is_absent(self, tmp_path: Path) -> None: + # Given a repository with no tasks directory repo = make_repo(tmp_path) - assert launch(repo, ["--dry-run", "a-001"]).returncode == USAGE_ERROR + + # When a task is planned + result = launch(repo, ["--dry-run", "a-001"]) + + # Then it is a usage error + assert result.returncode == USAGE_ERROR def test_reads_task_ids_from_stdin(self, tmp_path: Path) -> None: """Composes with whatever decided the tasks were ready.""" + # Given two tasks repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002") + + # When their ids come on stdin result = launch(repo, ["--dry-run"], stdin="a-001\na-002\n") + + # Then both would launch assert launched_ids(result.stdout) == ["a-001", "a-002"] def test_ignores_trailing_columns_on_stdin(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When its status.sh row comes on stdin result = launch(repo, ["--dry-run"], stdin="a-001\tready\t-\tDo a-001\n") + + # Then only the id is used assert launched_ids(result.stdout) == ["a-001"] @@ -203,28 +249,43 @@ class TestRunsAlone: """parallel: false means no other lane of the set runs while this one does.""" def test_a_task_that_runs_alone_holds_back_the_rest_of_the_batch(self, tmp_path: Path) -> None: + # Given a-001 runs alone repo = make_repo(tmp_path) add_task(repo, "a-001", alone=True) add_task(repo, "a-002") + + # When it is planned with a-002 result = launch(repo, ["--dry-run", "a-001", "a-002"]) + + # Then a-002 waits assert launched_ids(result.stdout) == ["a-001"] assert "SKIP\ta-002\ta-001 runs alone" in result.stdout def test_a_task_that_runs_alone_waits_for_lanes_already_started(self, tmp_path: Path) -> None: + # Given a-002 runs alone, between two shared tasks repo = make_repo(tmp_path) add_task(repo, "a-001") add_task(repo, "a-002", alone=True) add_task(repo, "a-003") + + # When the three are planned result = launch(repo, ["--dry-run", "a-001", "a-002", "a-003"]) + + # Then a-002 waits for the lanes started before it assert launched_ids(result.stdout) == ["a-001", "a-003"] assert "runs alone, and this batch already started lanes" in result.stdout def test_a_serial_set_launches_one_task(self, tmp_path: Path) -> None: """Every task false is what a serial set is; no --max-lanes 1 to remember.""" + # Given three tasks that each run alone repo = make_repo(tmp_path) for n in (1, 2, 3): add_task(repo, f"a-00{n}", alone=True) + + # When they are planned result = launch(repo, ["--dry-run", "a-001", "a-002", "a-003"]) + + # Then only the first would launch assert launched_ids(result.stdout) == ["a-001"] @@ -242,34 +303,55 @@ class TestTiers: @pytest.mark.parametrize("tier", TIERS) def test_every_documented_tier_is_launchable(self, tmp_path: Path, tier: str) -> None: + # Given a task on a documented tier repo = make_repo(tmp_path) add_task(repo, "a-001", tier=tier) + + # When it is planned result = launch(repo, ["--dry-run", "a-001"]) + + # Then it would launch assert result.returncode == OK, result.stderr assert result.stdout.startswith("WOULD-LAUNCH\ta-001") def test_an_unmapped_tier_leaves_harness_and_model_to_the_harness(self, tmp_path: Path) -> None: """No mapping, no model flag: the harness applies its own default.""" + # Given a plan task and no mapping repo = make_repo(tmp_path) add_task(repo, "a-001", tier="plan") + + # When it is planned result = launch(repo, ["--dry-run", "a-001"]) + + # Then harness and model are left to defaults assert fields(result.stdout)[HARNESS] == "auto" assert fields(result.stdout)[MODEL] == "default" def test_a_task_with_no_tier_is_a_build_task(self, tmp_path: Path) -> None: + # Given a task with no tier, and a build mapping repo = make_repo(tmp_path) add_task(repo, "a-001", tier=None) mapping = write_tiers(tmp_path / "tiers.conf", conf(build={"model": "sonnet"})) + + # When it is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_TIERS": str(mapping)}) + + # Then it gets the build model assert fields(result.stdout)[MODEL] == "sonnet" def test_an_unknown_tier_in_a_task_file_is_skipped_and_names_the_real_ones(self, tmp_path: Path) -> None: + # Given a task on an unknown tier repo = make_repo(tmp_path) add_task(repo, "a-001", tier="gpt4") + + # When it is planned result = launch(repo, ["--dry-run", "a-001"]) + + # Then it is skipped, naming the real tiers assert result.stdout.startswith("SKIP\ta-001\tunknown tier in task file: gpt4 (expected plan, build, or chore)") def test_a_mapping_picks_the_harness_and_the_model(self, tmp_path: Path) -> None: + # Given plan and chore tiers mapped to different harnesses repo = make_repo(tmp_path) add_task(repo, "a-001", tier="plan") add_task(repo, "a-002", tier="chore") @@ -277,64 +359,110 @@ def test_a_mapping_picks_the_harness_and_the_model(self, tmp_path: Path) -> None tmp_path / "tiers.conf", conf(plan={"harness": "claude", "model": "opus"}, chore={"harness": "copilot", "model": "gpt-6-luna"}), ) + + # When a task of each is planned result = launch(repo, ["--dry-run", "a-001", "a-002"], env_extra={"DELIVERY_TIERS": str(mapping)}) rows = [line.split("\t") for line in result.stdout.splitlines() if line.startswith("WOULD")] + + # Then each gets its tier's harness and model assert [r[HARNESS : MODEL + 1] for r in rows] == [["claude", "opus"], ["copilot", "gpt-6-luna"]] def test_harness_and_model_are_each_optional(self, tmp_path: Path) -> None: + # Given build mapping only a model, chore only a harness repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") add_task(repo, "a-002", tier="chore") mapping = write_tiers(tmp_path / "tiers.conf", conf(build={"model": "sonnet"}, chore={"harness": "claude"})) + + # When a task of each is planned result = launch(repo, ["--dry-run", "a-001", "a-002"], env_extra={"DELIVERY_TIERS": str(mapping)}) rows = [line.split("\t") for line in result.stdout.splitlines() if line.startswith("WOULD")] + + # Then the missing half is left to defaults assert [r[HARNESS : MODEL + 1] for r in rows] == [["auto", "sonnet"], ["claude", "default"]] def test_a_mapping_applies_only_to_the_harness_it_names(self, tmp_path: Path) -> None: """A model id means nothing to another harness: passing it through ends the session at startup.""" + # Given chore mapped to a copilot model repo = make_repo(tmp_path) add_task(repo, "a-001", tier="chore") mapping = write_tiers(tmp_path / "tiers.conf", conf(chore={"harness": "copilot", "model": "gpt-6-luna"})) + + # When a chore task is planned on claude result = launch(repo, ["--dry-run", "--harness", "claude", "a-001"], env_extra={"DELIVERY_TIERS": str(mapping)}) + + # Then the copilot model is not used assert fields(result.stdout)[HARNESS : MODEL + 1] == ["claude", "default"] def test_the_flag_wins_over_the_mapping_and_the_environment(self, tmp_path: Path) -> None: + # Given a mapping and the environment both naming claude repo = make_repo(tmp_path) add_task(repo, "a-001", tier="plan") mapping = write_tiers(tmp_path / "tiers.conf", conf(plan={"harness": "claude", "model": "opus"})) env = {"DELIVERY_TIERS": str(mapping), "DELIVERY_HARNESS": "claude"} + + # When --harness copilot is given result = launch(repo, ["--dry-run", "--harness", "copilot", "a-001"], env_extra=env) + + # Then copilot is used assert fields(result.stdout)[HARNESS] == "copilot" def test_the_mapping_wins_over_the_environment(self, tmp_path: Path) -> None: + # Given a mapping naming copilot and the environment claude repo = make_repo(tmp_path) add_task(repo, "a-001", tier="plan") mapping = write_tiers(tmp_path / "tiers.conf", conf(plan={"harness": "copilot"})) env = {"DELIVERY_TIERS": str(mapping), "DELIVERY_HARNESS": "claude"} + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra=env) + + # Then copilot is used assert fields(result.stdout)[HARNESS] == "copilot" def test_the_environment_picks_the_harness_when_nothing_else_does(self, tmp_path: Path) -> None: + # Given no mapping and DELIVERY_HARNESS set repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_HARNESS": "copilot"}) + + # Then the environment's harness is used assert fields(result.stdout)[HARNESS] == "copilot" def test_auto_is_accepted_as_a_choice(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") - assert launch(repo, ["--dry-run", "--harness", "auto", "a-001"]).returncode == OK - assert launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_HARNESS": "auto"}).returncode == OK + + # When auto is passed as the flag + result = launch(repo, ["--dry-run", "--harness", "auto", "a-001"]) + + # Then it is accepted + assert result.returncode == OK + + # When auto is set in the environment + result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_HARNESS": "auto"}) + + # Then it is accepted + assert result.returncode == OK def test_the_developers_file_is_read_from_the_xdg_config_directory(self, tmp_path: Path) -> None: + # Given a mapping in the XDG config directory repo = make_repo(tmp_path) add_task(repo, "a-001") mapping = conf(build={"harness": "claude", "model": "sonnet"}) write_tiers(tmp_path / "xdg" / "delivery-kit" / "tiers.conf", mapping) + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}) + + # Then that mapping is used assert fields(result.stdout)[HARNESS : MODEL + 1] == ["claude", "sonnet"] def test_the_project_file_is_the_default_and_the_developer_wins_per_tier(self, tmp_path: Path) -> None: + # Given a project mapping for plan and build, and a developer's for build repo = make_repo(tmp_path) add_task(repo, "a-001", tier="plan") add_task(repo, "a-002", tier="build") @@ -343,25 +471,39 @@ def test_the_project_file_is_the_default_and_the_developer_wins_per_tier(self, t conf(plan={"model": "opus"}, build={"model": "sonnet"}), ) mine = write_tiers(tmp_path / "mine.conf", conf(build={"model": "haiku"})) + + # When a task of each is planned result = launch(repo, ["--dry-run", "a-001", "a-002"], env_extra={"DELIVERY_TIERS": str(mine)}) rows = [line.split("\t") for line in result.stdout.splitlines() if line.startswith("WOULD")] + + # Then plan follows the project and build the developer assert [r[MODEL] for r in rows] == ["opus", "haiku"] def test_the_first_file_to_name_a_tier_decides_all_of_it(self, tmp_path: Path) -> None: """A developer's model must not be paired with a harness the project chose.""" + # Given the project maps build to a harness and model, the developer only a model repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") write_tiers(repo / "delivery" / "tiers.conf", conf(build={"harness": "copilot", "model": "team-model"})) mine = write_tiers(tmp_path / "mine.conf", conf(build={"model": "my-model"})) + + # When a build task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_TIERS": str(mine)}) + + # Then the developer's tier is taken whole, without the project's harness assert fields(result.stdout)[HARNESS : MODEL + 1] == ["auto", "my-model"] def test_comments_and_blank_lines_are_ignored(self, tmp_path: Path) -> None: + # Given a mapping with comments, blank lines and indentation repo = make_repo(tmp_path) add_task(repo, "a-001") text = "# my models\n\n[build]\n; the cheap one\n\tmodel = sonnet # my pick\n" mapping = write_tiers(tmp_path / "t.conf", text) + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_TIERS": str(mapping)}) + + # Then the model is read assert fields(result.stdout)[MODEL] == "sonnet" @pytest.mark.parametrize( @@ -384,24 +526,39 @@ def test_comments_and_blank_lines_are_ignored(self, tmp_path: Path) -> None: ) def test_a_malformed_mapping_stops_the_launch_and_says_where(self, tmp_path: Path, text: str, message: str) -> None: """A typo in a mapping must not quietly fall back to the harness default.""" + # Given a malformed mapping repo = make_repo(tmp_path) add_task(repo, "a-001") mapping = write_tiers(tmp_path / "t.conf", text) + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_TIERS": str(mapping)}) + + # Then the launch stops, naming the file and the problem assert result.returncode == USAGE_ERROR assert message in result.stderr def test_a_named_mapping_file_that_does_not_exist_is_an_error(self, tmp_path: Path) -> None: + # Given DELIVERY_TIERS naming a missing file repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_TIERS": str(tmp_path / "nope.conf")}) + + # Then it is a usage error assert result.returncode == USAGE_ERROR assert "DELIVERY_TIERS names a missing file" in result.stderr def test_an_unknown_harness_in_the_environment_is_an_error(self, tmp_path: Path) -> None: + # Given an unknown DELIVERY_HARNESS repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"], env_extra={"DELIVERY_HARNESS": "gemini"}) + + # Then it is a usage error assert result.returncode == USAGE_ERROR assert "DELIVERY_HARNESS must be" in result.stderr @@ -409,48 +566,90 @@ def test_an_unknown_harness_in_the_environment_is_an_error(self, tmp_path: Path) class TestLauncherUsage: def test_dies_without_task_ids(self, tmp_path: Path) -> None: repo = make_repo(tmp_path) - assert launch(repo, ["--dry-run"], stdin="").returncode == USAGE_ERROR + + # When nothing is given to plan + result = launch(repo, ["--dry-run"], stdin="") + + # Then it is a usage error + assert result.returncode == USAGE_ERROR def test_dies_on_an_unknown_harness(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") - assert launch(repo, ["--dry-run", "--harness", "gemini", "a-001"]).returncode == USAGE_ERROR + + # When an unknown harness is asked for + result = launch(repo, ["--dry-run", "--harness", "gemini", "a-001"]) + + # Then it is a usage error + assert result.returncode == USAGE_ERROR def test_dies_on_a_zero_lane_cap(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") - assert launch(repo, ["--dry-run", "--max-lanes", "0", "a-001"]).returncode == USAGE_ERROR + + # When the cap is zero + result = launch(repo, ["--dry-run", "--max-lanes", "0", "a-001"]) + + # Then it is a usage error + assert result.returncode == USAGE_ERROR def test_dry_run_creates_no_worktree(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a dry run plans it launch(repo, ["--dry-run", "a-001"]) + + # Then no worktree or branch exists assert not (tmp_path / "lanes").exists() assert "task/a-001" not in git(repo, "branch", "--list").stdout def test_worktrees_default_to_a_directory_named_after_this_repository(self, tmp_path: Path) -> None: """Clones side by side must not share ../lanes: task ids repeat across projects.""" + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When it is planned result = launch(repo, ["--dry-run", "a-001"]) + + # Then its worktree sits under a directory named after the repository assert f"\t../lanes/{repo.name}/a-001\t" in result.stdout def test_worktree_root_holds_the_task_ids_directly(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When a worktree root is given result = launch(repo, ["--dry-run", "--worktree-root", "/elsewhere/shop", "a-001"]) + + # Then the worktree sits directly under it assert "\t/elsewhere/shop/a-001\t" in result.stdout def test_the_environment_sets_the_worktree_root(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When the environment sets the worktree root result = launch(repo, ["--dry-run", "a-001"], worktree_root="/elsewhere/shop") + + # Then the worktree sits directly under it assert "\t/elsewhere/shop/a-001\t" in result.stdout def test_the_flag_wins_over_the_environment(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When both the flag and the environment set a root result = launch(repo, ["--dry-run", "--worktree-root", "/flag", "a-001"], worktree_root="/env") + + # Then the flag's root is used assert "\t/flag/a-001\t" in result.stdout @@ -563,11 +762,13 @@ class TestLaneStartup: def test_starts_the_agent_inside_the_lane_worktree(self, tmp_path: Path) -> None: """A session started in the wrong directory would edit the wrong checkout.""" + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -575,6 +776,7 @@ def test_starts_the_agent_inside_the_lane_worktree(self, tmp_path: Path) -> None log, ) + # Then the harness ran in the lane's worktree assert result.returncode == OK recorded = log.read_text(encoding="utf-8") assert recorded.strip(), "the harness was never invoked" @@ -582,10 +784,12 @@ def test_starts_the_agent_inside_the_lane_worktree(self, tmp_path: Path) -> None def test_the_lane_branch_pushes_to_itself(self, tmp_path: Path) -> None: """A lane started from the default branch must not track it, or a plain push goes astray.""" + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" + # When a lane is launched result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -593,6 +797,7 @@ def test_the_lane_branch_pushes_to_itself(self, tmp_path: Path) -> None: log, ) + # Then its branch's upstream is its own remote branch assert result.returncode == OK upstream = git(tmp_path / "lanes" / "a-001", "rev-parse", "--abbrev-ref", "task/a-001@{upstream}") assert upstream.stdout.strip() == "origin/task/a-001" @@ -620,77 +825,98 @@ def go(dev: Path, tmp_path: Path, bin_dir: Path, log: Path, *extra: str, env: di def test_auto_uses_the_first_harness_that_is_installed(self, tmp_path: Path) -> None: """No license for one vendor is required: a machine with only Copilot launches on Copilot.""" + # Given a machine with only copilot dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, names=("copilot",)) + # When a lane is launched result = self.go(dev, tmp_path, bin_dir, log) + # Then it runs on copilot assert result.returncode == OK, result.stderr assert result.stdout.strip().startswith("LAUNCHED\tb-001\tcopilot\tdefault") assert log.read_text(encoding="utf-8").strip(), "copilot was never invoked" def test_auto_prefers_claude_when_both_are_installed(self, tmp_path: Path) -> None: + # Given a machine with claude and copilot dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched result = self.go(dev, tmp_path, bin_dir, log) + # Then it runs on claude assert result.stdout.strip().startswith("LAUNCHED\tb-001\tclaude\tdefault") def test_no_installed_harness_skips_the_lane(self, tmp_path: Path) -> None: + # Given a machine with no harness dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, names=()) + # When a lane is launched result = self.go(dev, tmp_path, bin_dir, log) + # Then it is skipped, listing what was looked for assert result.stdout.strip() == f"SKIP\tb-001\tno supported harness on PATH ({ALL_HARNESSES})\nLANES\t0" def test_a_harness_somebody_chose_is_never_swapped_for_another(self, tmp_path: Path) -> None: """A lane on a harness nobody asked for would spend an allowance nobody meant to spend.""" + # Given a mapping choosing copilot on a machine with only claude dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, names=("claude",)) mapping = write_tiers(tmp_path / "t.conf", conf(build={"harness": "copilot"})) + # When a lane is launched result = self.go(dev, tmp_path, bin_dir, log, env={"DELIVERY_TIERS": str(mapping)}) + # Then it is skipped and nothing runs assert result.stdout.strip() == "SKIP\tb-001\tcopilot not found on PATH\nLANES\t0" assert not log.read_text(encoding="utf-8").strip(), "nothing should have been invoked" def test_an_unmapped_tier_passes_no_model_flag(self, tmp_path: Path) -> None: + # Given no mapping dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched self.go(dev, tmp_path, bin_dir, log) + # Then no model flag reaches the harness wait_for(lambda: log.read_text(encoding="utf-8").strip()) assert "--model" not in log.read_text(encoding="utf-8") @pytest.mark.parametrize("harness", ["claude", "copilot"]) def test_a_mapped_model_reaches_the_harness_it_is_for(self, tmp_path: Path, harness: str) -> None: + # Given build mapped to a harness and model dev = self.one_task_remote(tmp_path) log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) mapping = write_tiers(tmp_path / "t.conf", conf(build={"harness": harness, "model": "some-model"})) + # When a lane is launched result = self.go(dev, tmp_path, bin_dir, log, env={"DELIVERY_TIERS": str(mapping)}) + # Then that harness gets that model assert f"LAUNCHED\tb-001\t{harness}\tsome-model" in result.stdout wait_for(lambda: log.read_text(encoding="utf-8").strip()) assert "--model some-model" in log.read_text(encoding="utf-8") def test_a_model_mapped_for_another_harness_is_not_passed_on(self, tmp_path: Path) -> None: + # Given chore mapped to a copilot model dev = self.one_task_remote(tmp_path, tier="chore") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) mapping = write_tiers(tmp_path / "t.conf", conf(chore={"harness": "copilot", "model": "gpt-6-luna"})) + # When a chore lane is launched on claude result = self.go(dev, tmp_path, bin_dir, log, "--harness", "claude", env={"DELIVERY_TIERS": str(mapping)}) + # Then the copilot model never reaches claude assert "LAUNCHED\tb-001\tclaude\tdefault" in result.stdout wait_for(lambda: log.read_text(encoding="utf-8").strip()) assert "gpt-6-luna" not in log.read_text(encoding="utf-8") @@ -702,11 +928,14 @@ def test_refuses_to_start_without_a_usable_claim_script(self, tmp_path: Path) -> without it started every ready task, printed LAUNCHED for each, and left five developers free to take the same one. """ + # Given a claim script that does not exist repo = make_repo(tmp_path) add_task(repo, "a-001") + # When a lane is launched result = launch(repo, ["--claim-script", str(tmp_path / "absent.sh"), "a-001"]) + # Then it is a usage error and nothing launches assert result.returncode == USAGE_ERROR assert "claim script not found" in result.stderr assert "LAUNCHED" not in result.stdout @@ -717,6 +946,7 @@ def test_a_broken_claim_script_is_not_reported_as_a_lost_race(self, tmp_path: Pa Both used to print the same line, so a misconfigured claim script looked exactly like somebody else holding every task. """ + # Given a claim script that exits 9 origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") broken = tmp_path / "broken-claim.sh" @@ -724,6 +954,7 @@ def test_a_broken_claim_script_is_not_reported_as_a_lost_race(self, tmp_path: Pa log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched result = launch_live( dev, ["--claim-script", str(broken), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -731,6 +962,7 @@ def test_a_broken_claim_script_is_not_reported_as_a_lost_race(self, tmp_path: Pa log, ) + # Then the failure is reported as such, and no session starts assert "claim failed (exit 9)" in result.stdout assert "already claimed" not in result.stdout assert not log.read_text(encoding="utf-8").strip(), "no session may start on a failed claim" @@ -742,11 +974,13 @@ def test_invokes_the_skill_as_a_slash_command(self, tmp_path: Path) -> None: treats the skill's boundaries as advisory, and the failure is invisible in the log. See docs/harness-validation.md, test 4. """ + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -754,16 +988,19 @@ def test_invokes_the_skill_as_a_slash_command(self, tmp_path: Path) -> None: log, ) + # Then the skill is invoked by slash command with the task file recorded = log.read_text(encoding="utf-8") assert "/delivery-implement " in recorded assert "a-001.md" in recorded, "the task file must reach the skill as its argument" def test_passes_the_deny_rules_to_the_session(self, tmp_path: Path) -> None: + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -771,6 +1008,7 @@ def test_passes_the_deny_rules_to_the_session(self, tmp_path: Path) -> None: log, ) + # Then the session gets the deny rules assert "terraform apply" in log.read_text(encoding="utf-8") def test_lets_a_claude_lane_run_shell_commands(self, tmp_path: Path) -> None: @@ -779,11 +1017,13 @@ def test_lets_a_claude_lane_run_shell_commands(self, tmp_path: Path) -> None: The lane then cannot run a single test, and still exits 0. See docs/harness-validation.md, test 6. """ + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a claude lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "--harness", "claude", "a-001"], @@ -791,6 +1031,7 @@ def test_lets_a_claude_lane_run_shell_commands(self, tmp_path: Path) -> None: log, ) + # Then it may run shell commands, minus the denied ones recorded = log.read_text(encoding="utf-8") assert "--allowedTools Bash" in recorded assert "--disallowedTools Bash(terraform apply:*)" in recorded @@ -812,6 +1053,7 @@ def test_web_access_is_on_unless_turned_off( by removing both web tools, one of which comes from its GitHub MCP server. """ for web in ("on", "off"): + # Given a fresh clone for each setting, with --no-web for off root = tmp_path / web origin_path = make_shared_origin(root) dev = clone_of(root, origin_path, "devA") @@ -819,6 +1061,7 @@ def test_web_access_is_on_unless_turned_off( bin_dir = fake_harness(root, log) flags = ["--no-web"] if web == "off" else [] + # When a lane is launched launch_live( dev, [ @@ -835,6 +1078,7 @@ def test_web_access_is_on_unless_turned_off( log, ) + # Then the web tools follow the setting recorded = log.read_text(encoding="utf-8") if web == "on": assert web_on is None or web_on in recorded @@ -845,11 +1089,13 @@ def test_web_access_is_on_unless_turned_off( def test_asks_the_lane_for_a_report(self, tmp_path: Path) -> None: """The report becomes the pull request body, so the lane has to write it.""" + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -857,15 +1103,18 @@ def test_asks_the_lane_for_a_report(self, tmp_path: Path) -> None: log, ) + # Then the session is asked for the report assert "a-001.md --report .lane-report.md" in log.read_text(encoding="utf-8") def test_a_clean_session_ends_in_a_draft_pull_request(self, tmp_path: Path) -> None: """The session leaves its change uncommitted; the launcher publishes it.""" + # Given a session that leaves a change origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, edits=True) + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -873,6 +1122,7 @@ def test_a_clean_session_ends_in_a_draft_pull_request(self, tmp_path: Path) -> N log, ) + # Then the change is pushed and a draft pull request opens gh_log = tmp_path / "gh.log" deadline = time.monotonic() + 20 while "pr create" not in (gh_log.read_text(encoding="utf-8") if gh_log.exists() else ""): @@ -884,12 +1134,14 @@ def test_a_clean_session_ends_in_a_draft_pull_request(self, tmp_path: Path) -> N def test_a_session_that_fails_at_startup_gives_its_task_back(self, tmp_path: Path) -> None: """Otherwise the task stays held by a lane that never ran.""" + # Given a session that exits 1 origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log, fails=True) logs = tmp_path / "lanelogs" + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "--log-dir", str(logs), "a-001"], @@ -897,6 +1149,7 @@ def test_a_session_that_fails_at_startup_gives_its_task_back(self, tmp_path: Pat log, ) + # Then the claim, worktree and pull request are all gone lane_log = logs / "a-001.log" deadline = time.monotonic() + 20 while "RELEASED" not in (lane_log.read_text(encoding="utf-8") if lane_log.exists() else ""): @@ -909,6 +1162,7 @@ def test_a_session_that_fails_at_startup_gives_its_task_back(self, tmp_path: Pat def test_a_task_that_runs_alone_waits_for_another_developers_lane(self, tmp_path: Path) -> None: """The batch is not the whole picture: in-flight work lives on the remote.""" + # Given devA holding a-001, and a-002 that runs alone origin_path = make_shared_origin(tmp_path) seed = clone_of(tmp_path, origin_path, "seed2") add_task(seed, "a-002", alone=True) @@ -923,14 +1177,18 @@ def test_a_task_that_runs_alone_waits_for_another_developers_lane(self, tmp_path launch_live( holder, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lA"), "a-001"], bin_dir, log ) + + # When devB launches a-002 result = launch_live( latecomer, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lB"), "a-002"], bin_dir, log ) + # Then it waits for devA's lane assert "SKIP\ta-002\truns alone, and tasks are in flight: a-001" in result.stdout assert not (tmp_path / "lB" / "a-002").exists() def test_nothing_starts_while_a_task_that_runs_alone_is_in_flight(self, tmp_path: Path) -> None: + # Given devA holding a-002, which runs alone origin_path = make_shared_origin(tmp_path) seed = clone_of(tmp_path, origin_path, "seed2") add_task(seed, "a-002", alone=True) @@ -945,10 +1203,13 @@ def test_nothing_starts_while_a_task_that_runs_alone_is_in_flight(self, tmp_path launch_live( holder, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lA"), "a-002"], bin_dir, log ) + + # When devB launches a-001 result = launch_live( latecomer, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lB"), "a-001"], bin_dir, log ) + # Then it waits assert "SKIP\ta-001\ta-002 runs alone and is in flight" in result.stdout def test_a_lane_starts_from_the_remote_not_a_stale_checkout(self, tmp_path: Path) -> None: @@ -958,6 +1219,7 @@ def test_a_lane_starts_from_the_remote_not_a_stale_checkout(self, tmp_path: Path lane branched from its HEAD lacked the code it depended on, and its pull request conflicted. Found by running the follow loop on a real task set. """ + # Given a dependency merged on the remote since the clone origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") merger = clone_of(tmp_path, origin_path, "merger") @@ -968,35 +1230,42 @@ def test_a_lane_starts_from_the_remote_not_a_stale_checkout(self, tmp_path: Path log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], bin_dir, log ) + # Then its worktree has the dependency assert result.returncode == OK, result.stdout + result.stderr assert (tmp_path / "lanes" / "a-001" / "dependency.py").exists() def test_a_repository_without_origin_head_still_launches(self, tmp_path: Path) -> None: """A repository created locally and pushed has no origin/HEAD; a clone does.""" + # Given a clone without origin/HEAD origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") git(dev, "remote", "set-head", "origin", "--delete") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched result = launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], bin_dir, log ) + # Then its worktree exists assert result.returncode == OK, result.stdout + result.stderr assert (tmp_path / "lanes" / "a-001").is_dir() def test_writes_a_session_log_for_the_lane(self, tmp_path: Path) -> None: + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) logs = tmp_path / "lanelogs" + # When a lane is launched with a log directory launch_live( dev, [ @@ -1012,14 +1281,17 @@ def test_writes_a_session_log_for_the_lane(self, tmp_path: Path) -> None: log, ) + # Then the lane's log is there assert (logs / "a-001.log").exists() def test_claims_the_task_on_the_remote(self, tmp_path: Path) -> None: + # Given a clone of the shared remote origin_path = make_shared_origin(tmp_path) dev = clone_of(tmp_path, origin_path, "devA") log = tmp_path / "agent.log" bin_dir = fake_harness(tmp_path, log) + # When a lane is launched launch_live( dev, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], @@ -1027,11 +1299,13 @@ def test_claims_the_task_on_the_remote(self, tmp_path: Path) -> None: log, ) + # Then the claim branch is on the remote refs = git(origin_path, "for-each-ref", "--format=%(refname)", "refs/heads/task/").stdout assert "refs/heads/task/a-001" in refs def test_a_lost_claim_leaves_no_worktree_or_branch_behind(self, tmp_path: Path) -> None: """Losing a claim is routine. It must not litter the loser's checkout.""" + # Given devA holding a-001 origin_path = make_shared_origin(tmp_path) holder = clone_of(tmp_path, origin_path, "devA") latecomer = clone_of(tmp_path, origin_path, "devB") @@ -1044,6 +1318,8 @@ def test_a_lost_claim_leaves_no_worktree_or_branch_behind(self, tmp_path: Path) bin_dir, log, ) + + # When devB launches it too result = launch_live( latecomer, ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanesB"), "a-001"], @@ -1051,6 +1327,7 @@ def test_a_lost_claim_leaves_no_worktree_or_branch_behind(self, tmp_path: Path) log, ) + # Then devB is left with no worktree or branch assert result.returncode == NO_LANES assert "already claimed" in result.stdout assert not (tmp_path / "lanesB" / "a-001").exists() @@ -1059,9 +1336,12 @@ def test_a_lost_claim_leaves_no_worktree_or_branch_behind(self, tmp_path: Path) def test_refuses_to_launch_without_delivery_implement_beside_it(tmp_path: Path) -> None: """A lane is told to follow delivery-implement; without it the session would run with no skill.""" + # Given an install without delivery-implement skills = tmp_path / "skills" shutil.copytree(SKILLS, skills) shutil.rmtree(skills / "delivery-implement") + + # When the launcher runs from it result = subprocess.run( # noqa: S603 [SH, str(skills / "delivery-orchestrator" / "scripts" / "launch-lanes.sh"), "--dry-run", "a-001"], cwd=tmp_path, @@ -1069,6 +1349,8 @@ def test_refuses_to_launch_without_delivery_implement_beside_it(tmp_path: Path) text=True, check=False, ) + + # Then it refuses, naming the missing skill assert result.returncode == 1 assert "delivery-implement is not installed beside delivery-orchestrator" in result.stderr @@ -1086,6 +1368,7 @@ def rows(self, repo: Path, tmp_path: Path, mapping: str, *args: str) -> list[lis return [line.split("\t") for line in result.stdout.splitlines() if line.startswith("WOULD-LAUNCH")] def test_each_tier_is_reviewed_one_tier_up(self, tmp_path: Path) -> None: + # Given a task on each tier, each tier mapped to a model repo = make_repo(tmp_path) add_task(repo, "a-001", tier="chore") add_task(repo, "a-002", tier="build") @@ -1095,7 +1378,11 @@ def test_each_tier_is_reviewed_one_tier_up(self, tmp_path: Path) -> None: build={"harness": "claude", "model": "sonnet"}, plan={"harness": "claude", "model": "opus"}, ) + + # When they are planned rows = self.rows(repo, tmp_path, mapping, "a-001", "a-002", "a-003") + + # Then each is reviewed on the tier above's model, plan on its own assert [r[MODEL : REFINE_MODEL + 1] for r in rows] == [ ["haiku", "claude", "sonnet"], ["sonnet", "claude", "opus"], @@ -1103,39 +1390,64 @@ def test_each_tier_is_reviewed_one_tier_up(self, tmp_path: Path) -> None: ] def test_the_tier_above_may_review_on_another_harness(self, tmp_path: Path) -> None: + # Given build on copilot and plan on claude repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") mapping = conf(build={"harness": "copilot", "model": "gpt-6"}, plan={"harness": "claude", "model": "opus"}) + + # When a build task is planned row = self.rows(repo, tmp_path, mapping, "a-001")[0] + + # Then it is reviewed on claude assert row[HARNESS : REFINE_MODEL + 1] == ["copilot", "gpt-6", "claude", "opus"] def test_a_tier_s_own_reviewer_wins_on_its_harness(self, tmp_path: Path) -> None: + # Given build with its own reviewer repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") mapping = conf( build={"harness": "copilot", "model": "gpt-6", "reviewer": "gpt-6-pro"}, plan={"harness": "claude", "model": "opus"}, ) + + # When a build task is planned row = self.rows(repo, tmp_path, mapping, "a-001")[0] + + # Then the reviewer is used on build's harness assert row[REFINE_HARNESS : REFINE_MODEL + 1] == ["copilot", "gpt-6-pro"] def test_unmapped_tiers_review_on_the_harness_default(self, tmp_path: Path) -> None: + # Given no mapping repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") + + # When a task is planned result = launch(repo, ["--dry-run", "a-001"]) + + # Then the review uses the defaults assert fields(result.stdout)[REFINE_HARNESS : REFINE_MODEL + 1] == ["auto", "default"] def test_the_harness_flag_holds_for_the_review_too(self, tmp_path: Path) -> None: + # Given plan mapped to claude repo = make_repo(tmp_path) add_task(repo, "a-001", tier="build") mapping = conf(plan={"harness": "claude", "model": "opus"}) + + # When a build task is planned with --harness copilot row = self.rows(repo, tmp_path, mapping, "--harness", "copilot", "a-001")[0] + + # Then the review stays on copilot assert row[REFINE_HARNESS : REFINE_MODEL + 1] == ["copilot", "default"] def test_no_refine_turns_it_off(self, tmp_path: Path) -> None: + # Given one task repo = make_repo(tmp_path) add_task(repo, "a-001") + + # When it is planned with --no-refine result = launch(repo, ["--dry-run", "--no-refine", "a-001"]) + + # Then no review is planned assert fields(result.stdout)[REFINE_HARNESS : REFINE_MODEL + 1] == ["-", "-"] @@ -1180,8 +1492,13 @@ def prompts(log: Path) -> list[str]: return [line for line in log.read_text(encoding="utf-8").splitlines() if line.startswith("-p /delivery")] def test_the_change_is_refined_on_the_tier_above_before_it_is_published(self, tmp_path: Path) -> None: + # Given build on sonnet and plan on opus, and a session that leaves a change mapping = conf(build={"harness": "claude", "model": "sonnet"}, plan={"harness": "claude", "model": "opus"}) + + # When a lane runs log, lane_log = self.run(tmp_path, mapping=mapping, edits=True) + + # Then implement runs on sonnet, refine on opus, then the lane publishes prompts = self.prompts(log) assert [p.split()[1] for p in prompts] == ["/delivery-implement", "/delivery-refine"] assert prompts[0].startswith("-p /delivery-implement delivery/tasks/a-001.md --report .lane-report.md") @@ -1192,8 +1509,13 @@ def test_the_change_is_refined_on_the_tier_above_before_it_is_published(self, tm assert "PUBLISHED" in lane_log.read_text(encoding="utf-8") def test_an_advisor_without_a_model_stays_an_advisor(self, tmp_path: Path) -> None: + # Given a tier above with an advisor but no model mapping = conf(build={"harness": "claude", "model": "sonnet"}, plan={"harness": "claude", "advisor": "opus"}) + + # When a lane runs log, _ = self.run(tmp_path, mapping=mapping, edits=True) + + # Then the review gets the advisor and no model refine = self.prompts(log)[1] assert "--advisor opus" in refine assert "--model" not in refine @@ -1201,24 +1523,38 @@ def test_an_advisor_without_a_model_stays_an_advisor(self, tmp_path: Path) -> No @pytest.mark.parametrize("above", ["copilot", "custom"]) def test_the_lane_harness_reviews_when_the_tier_above_cannot_run(self, tmp_path: Path, above: str) -> None: """copilot is not installed, and custom has no DELIVERY_LANE_CMD.""" + # Given a tier above on a harness that cannot run here mapping = conf(build={"harness": "claude", "model": "sonnet"}, plan={"harness": above, "model": "opus"}) + + # When a lane runs log, lane_log = self.run(tmp_path, mapping=mapping, forced=None, names=("claude",), edits=True) + + # Then the lane's own harness reviews, on its default model assert [p.split()[1] for p in self.prompts(log)] == ["/delivery-implement", "/delivery-refine"] assert "REFINE\tclaude\tdefault" in lane_log.read_text(encoding="utf-8") def test_nothing_to_refine_when_the_implementation_left_no_change(self, tmp_path: Path) -> None: + # When a lane runs and leaves no change log, lane_log = self.run(tmp_path) + + # Then no refinement runs assert [p.split()[1] for p in self.prompts(log)] == ["/delivery-implement"] assert "REFINE" not in lane_log.read_text(encoding="utf-8") def test_no_refine_publishes_the_implementation_as_it_is(self, tmp_path: Path) -> None: + # When a lane runs with --no-refine log, lane_log = self.run(tmp_path, "--no-refine", edits=True) + + # Then only implement runs, and the lane publishes assert [p.split()[1] for p in self.prompts(log)] == ["/delivery-implement"] assert "PUBLISHED" in lane_log.read_text(encoding="utf-8") def test_a_failed_refinement_still_publishes_and_says_so(self, tmp_path: Path) -> None: """The implementation was validated; losing it to a failed review would waste it.""" + # When a lane runs and its refinement fails _, lane_log = self.run(tmp_path, edits=True, fails_on="delivery-refine") + + # Then the lane publishes, and the log and report say it failed text = lane_log.read_text(encoding="utf-8") assert "REFINE-FAILED\t1" in text assert "PUBLISHED" in text @@ -1228,9 +1564,12 @@ def test_a_failed_refinement_still_publishes_and_says_so(self, tmp_path: Path) - def test_refuses_to_launch_without_delivery_refine_beside_it(tmp_path: Path) -> None: + # Given an install without delivery-refine skills = tmp_path / "skills" shutil.copytree(SKILLS, skills) shutil.rmtree(skills / "delivery-refine") + + # When the launcher runs from it result = subprocess.run( # noqa: S603 [SH, str(skills / "delivery-orchestrator" / "scripts" / "launch-lanes.sh"), "--dry-run", "a-001"], cwd=tmp_path, @@ -1238,5 +1577,7 @@ def test_refuses_to_launch_without_delivery_refine_beside_it(tmp_path: Path) -> text=True, check=False, ) + + # Then it refuses, naming the missing skill assert result.returncode == 1 assert "delivery-refine is not installed beside delivery-orchestrator" in result.stderr diff --git a/tests/unit/test_multi_repo.py b/tests/unit/test_multi_repo.py index 173cfa4..1eecd66 100644 --- a/tests/unit/test_multi_repo.py +++ b/tests/unit/test_multi_repo.py @@ -154,50 +154,62 @@ def claim_in(repo: Path, task_id: str) -> None: class TestStatusAcrossRepositories: def test_a_task_is_claimed_in_the_repository_it_lands_in(self, tmp_path: Path, use_case: tuple[Path, Path]) -> None: + # Given an api task claimed in the api clone home, api = use_case task(home, "h-001") task(home, "a-001", repo="api") claim_in(api, "a-001") + # When status is read from home result = run(STATUS, [], home, fake_gh(tmp_path)) + # Then the api task is in progress there assert result.returncode == 0, result.stderr assert states(result.stdout) == {"a-001": "in-progress", "h-001": "ready"} def test_a_branch_of_the_same_name_in_another_repository_is_not_the_task( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: + # Given a home task's branch claimed and merged in the api clone home, api = use_case task(home, "h-001") task(home, "a-001", repo="api") claim_in(api, "h-001") + # When status is read from home result = run(STATUS, [], home, fake_gh(tmp_path, merged={"api": ["h-001"]})) + # Then neither task takes that branch as its own assert states(result.stdout) == {"a-001": "ready", "h-001": "ready"} def test_a_merge_in_another_repository_unblocks_a_dependent_here( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: """Dependencies cross repositories: the API contract lands before its consumer.""" + # Given a home task that depends on an api task merged in api home, _ = use_case task(home, "a-001", repo="api") task(home, "h-001", depends_on="a-001") + # When status is read from home result = run(STATUS, ["--no-fetch"], home, fake_gh(tmp_path, merged={"api": ["a-001"]})) + # Then the dependent is ready assert states(result.stdout) == {"a-001": "done", "h-001": "ready"} def test_a_snapshot_names_the_repository_and_pull_request_of_each_task( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: """A connector files each task under the repository it changes, not the plan's.""" + # Given a merged api task and a home task depending on it home, _ = use_case task(home, "a-001", repo="api") task(home, "h-001", depends_on="a-001") + # When a snapshot is taken result = run(SNAPSHOT, ["--no-fetch"], home, fake_gh(tmp_path, merged={"api": ["a-001"]})) + # Then each task carries its repository, and the api one its pull request assert result.returncode == 0, result.stderr tasks = {t["id"]: t for t in json.loads(result.stdout)["tasks"]} assert (tasks["a-001"]["repo"], tasks["a-001"]["state"]) == ("api", "done") @@ -207,23 +219,29 @@ def test_a_snapshot_names_the_repository_and_pull_request_of_each_task( def test_a_task_in_a_repository_with_no_clone_is_an_error( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: + # Given a task for a repository not cloned beside home home, _ = use_case task(home, "w-001", repo="webapp") + # When status is read result = run(STATUS, ["--no-fetch"], home, fake_gh(tmp_path)) + # Then it fails, saying where to clone it assert result.returncode == ERROR assert "clone it beside home" in result.stderr class TestLaunchAcrossRepositories: def test_a_dry_run_names_the_repository_of_each_lane(self, tmp_path: Path, use_case: tuple[Path, Path]) -> None: + # Given one home task and one api task home, _ = use_case task(home, "h-001") task(home, "a-001", repo="api") + # When a dry run plans both result = run(LAUNCH, ["--dry-run", "h-001", "a-001"], home, fake_gh(tmp_path)) + # Then each lane names its repository lanes = [line.split("\t") for line in result.stdout.splitlines() if line.startswith("WOULD-LAUNCH")] assert [(lane[1], lane[-1]) for lane in lanes] == [("h-001", "home"), ("a-001", "api")] @@ -231,6 +249,7 @@ def test_a_dry_run_names_the_repository_of_each_lane(self, tmp_path: Path, use_c def test_a_lane_works_in_its_repository_and_reads_the_plan_here( self, tmp_path: Path, use_case: tuple[Path, Path], harness: str ) -> None: + # Given an api task planned in home, and a harness that logs where it runs home, api = use_case task(home, "a-001", repo="api") bin_dir = fake_gh(tmp_path) @@ -241,6 +260,7 @@ def test_a_lane_works_in_its_repository_and_reads_the_plan_here( (bin_dir / harness).chmod(0o755) lanes = tmp_path / "uc" / "lanes" + # When its lane is launched from home result = run( LAUNCH, ["--harness", harness, "--worktree-root", "../lanes", "a-001"], @@ -249,6 +269,7 @@ def test_a_lane_works_in_its_repository_and_reads_the_plan_here( FAKE_LOG=str(log), ) + # Then the branch lives in api, and the harness runs there with home readable assert result.returncode == 0, result.stdout + result.stderr assert "task/a-001" in git(api, "branch", "--list", "task/a-001") assert "task/a-001" not in git(home, "branch", "--list", "task/a-001") @@ -267,6 +288,7 @@ def test_a_lane_works_in_its_repository_and_reads_the_plan_here( def test_a_lane_in_this_repository_gets_no_extra_directory( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: + # Given a home task, and a harness that logs its arguments home, _ = use_case task(home, "h-001") git(home, "add", "-A") @@ -277,8 +299,10 @@ def test_a_lane_in_this_repository_gets_no_extra_directory( (bin_dir / "claude").write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$FAKE_LOG"\n', encoding="utf-8") (bin_dir / "claude").chmod(0o755) + # When its lane is launched run(LAUNCH, ["--harness", "claude", "--worktree-root", "../lanes", "h-001"], home, bin_dir, FAKE_LOG=str(log)) + # Then the harness gets the plain skill prompt and no extra directory deadline = time.monotonic() + 10 while not log.exists() or not log.read_text(encoding="utf-8").strip(): assert time.monotonic() < deadline, "the harness was never invoked" @@ -292,6 +316,7 @@ class TestPublishAcrossRepositories: def test_the_commit_and_pull_request_link_the_task_where_it_is_planned( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: + # Given a claimed api lane with a change, planned in home home, api = use_case task(home, "a-001", repo="api") git(home, "remote", "set-url", "origin", "git@example.test:org/home.git") @@ -301,8 +326,10 @@ def test_the_commit_and_pull_request_link_the_task_where_it_is_planned( (api / ".lane-report.md").write_text("Did it.\n", encoding="utf-8") bin_dir = fake_gh(tmp_path) + # When it is published result = run(PUBLISH, [str(home / "delivery" / "tasks" / "a-001.md")], api, bin_dir) + # Then the commit and the body link the task file in home assert result.returncode == 0, result.stderr link = "https://example.test/org/home/blob/HEAD/delivery/tasks/a-001.md" assert f"Task: {link}" in git(api, "log", "-1", "--format=%b") @@ -311,25 +338,41 @@ def test_the_commit_and_pull_request_link_the_task_where_it_is_planned( class TestValidateAcrossRepositories: def test_a_repo_with_a_clone_beside_this_one_is_valid(self, use_case: tuple[Path, Path]) -> None: + # Given an api task, with api cloned beside home home, _ = use_case task(home, "a-001", repo="api") write_summary(home / "delivery" / "tasks") - assert validate(home / "delivery" / "tasks").returncode == 0 + + # When the task set is validated + result = validate(home / "delivery" / "tasks") + + # Then it is valid + assert result.returncode == 0 def test_a_repo_with_no_clone_is_rejected(self, use_case: tuple[Path, Path]) -> None: + # Given a task for a repository not cloned beside home home, _ = use_case task(home, "w-001", repo="webapp") write_summary(home / "delivery" / "tasks") + + # When the task set is validated result = validate(home / "delivery" / "tasks") + + # Then it is rejected, saying where to clone it assert result.returncode == INVALID assert "clone it beside home" in result.stdout + result.stderr @pytest.mark.parametrize("bad", ["../api", "org/api", ".."]) def test_a_repo_must_be_a_plain_directory_name(self, use_case: tuple[Path, Path], bad: str) -> None: + # Given a task whose repo is a path, not a name home, _ = use_case task(home, "a-001", repo=bad) write_summary(home / "delivery" / "tasks") + + # When the task set is validated result = validate(home / "delivery" / "tasks") + + # Then it is rejected assert result.returncode == INVALID assert "directory name of a sibling clone" in result.stdout + result.stderr @@ -338,6 +381,7 @@ class TestReviewAcrossRepositories: def test_a_pull_request_is_read_from_its_repository_and_named_with_it( self, tmp_path: Path, use_case: tuple[Path, Path] ) -> None: + # Given a task in review in api and another in home home, _ = use_case (home / "delivery").mkdir(exist_ok=True) (home / "delivery" / "prd.md").write_text("# PRD\n\n**Status:** approved\n", encoding="utf-8") @@ -348,8 +392,10 @@ def test_a_pull_request_is_read_from_its_repository_and_named_with_it( task(home, "h-001") bin_dir = fake_gh(tmp_path, in_review={"api": {"a-001": 7}, "home": {"h-001": 3}}) + # When where.sh runs from home result = run(WHERE, ["--no-fetch"], home, bin_dir) + # Then the api pull request is named with its repository assert result.returncode == 0, result.stderr reviews = [line.split("\t")[1:3] for line in result.stdout.splitlines() if line.startswith("REVIEW\t")] assert reviews == [["a-001", "api#7"], ["h-001", "#3"]] diff --git a/tests/unit/test_posix_sh.py b/tests/unit/test_posix_sh.py index 1ad2fd4..51b14a9 100644 --- a/tests/unit/test_posix_sh.py +++ b/tests/unit/test_posix_sh.py @@ -15,20 +15,27 @@ def test_helpers_were_found() -> None: - assert len(SCRIPTS) == EXPECTED_HELPERS + # When the helpers are collected from every skill's scripts directory + found = len(SCRIPTS) + + # Then none was renamed out of the glob + assert found == EXPECTED_HELPERS @pytest.mark.parametrize("script", EXECUTABLES, ids=lambda p: p.name) def test_a_helper_declares_posix_sh(script) -> None: + # When its header is read lines = script.read_text().splitlines() + + # Then it declares POSIX sh and strict mode assert lines[0] == "#!/bin/sh" assert lines[1] == "set -eu" def test_the_adapters_are_linted_as_sh() -> None: """harness.sh is sourced, so it has no shebang to tell ShellCheck its dialect.""" - assert ( - PROJECT_DIR.joinpath(".agents/skills/delivery-orchestrator/scripts/harness.sh") - .read_text() - .startswith("# shellcheck shell=sh\n") - ) + # When the sourced adapters are read + text = PROJECT_DIR.joinpath(".agents/skills/delivery-orchestrator/scripts/harness.sh").read_text() + + # Then their first line names the dialect instead + assert text.startswith("# shellcheck shell=sh\n") diff --git a/tests/unit/test_publish_lane_script.py b/tests/unit/test_publish_lane_script.py index 39f7c3a..392d0ea 100644 --- a/tests/unit/test_publish_lane_script.py +++ b/tests/unit/test_publish_lane_script.py @@ -108,11 +108,14 @@ def remote_subject(tmp_path: Path) -> str: class TestPublishing: def test_a_finished_lane_becomes_a_draft_pull_request(self, tmp_path: Path) -> None: + # Given a claimed lane with an uncommitted change lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When it is published result = publish(lane, tmp_path) + # Then the change is pushed and a labelled draft pull request opens assert result.returncode == OK, result.stderr assert result.stdout == f"PUBLISHED\ta-001\t{URL}\n" assert remote_subject(tmp_path) == "feat(a): do a-001" @@ -122,24 +125,30 @@ def test_a_finished_lane_becomes_a_draft_pull_request(self, tmp_path: Path) -> N def test_the_body_carries_the_criteria_and_the_report(self, tmp_path: Path) -> None: """The reviewer checks the diff against the criteria, and the report says what was run.""" + # Given a lane with a change and a report lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") (lane / ".lane-report.md").write_text("Validation run: pytest, 3 passed.\n", encoding="utf-8") + # When it is published publish(lane, tmp_path) + # Then the body has the criteria and the report, and nothing after the criteria body = (tmp_path / "body.md").read_text(encoding="utf-8") assert "- the widget renders" in body assert "Validation run: pytest, 3 passed." in body assert "Run it." not in body, "the section after the criteria leaked into the body" def test_the_report_is_never_committed(self, tmp_path: Path) -> None: + # Given a lane with a change and a report lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") (lane / ".lane-report.md").write_text("report\n", encoding="utf-8") + # When it is published publish(lane, tmp_path) + # Then the change is pushed without the report files = git(tmp_path / "origin.git", "ls-tree", "-r", "--name-only", "task/a-001").stdout.split() assert "widget.py" in files assert ".lane-report.md" not in files @@ -154,6 +163,7 @@ def test_the_report_is_never_committed(self, tmp_path: Path) -> None: ) def test_a_repository_template_makes_the_report_the_whole_body(self, tmp_path: Path, template: str) -> None: """The lane fills the template; wrapping it in the kit's own sections would break it.""" + # Given a repository pull request template, and a report that fills it lane = claimed_lane(tmp_path) (lane / template).parent.mkdir(parents=True, exist_ok=True) (lane / template).write_text("## Summary\n\n## Testing\n", encoding="utf-8") @@ -161,120 +171,155 @@ def test_a_repository_template_makes_the_report_the_whole_body(self, tmp_path: P filled = "## Summary\n\nAdds the widget. Task: delivery/tasks/a-001.md\n\n## Testing\n\npytest\n" (lane / ".lane-report.md").write_text(filled, encoding="utf-8") + # When the lane is published publish(lane, tmp_path) + # Then the body is the report, unwrapped assert (tmp_path / "body.md").read_text(encoding="utf-8") == filled def test_without_a_report_the_template_does_not_hide_the_criteria(self, tmp_path: Path) -> None: + # Given a repository template but no report lane = claimed_lane(tmp_path) (lane / ".github").mkdir() (lane / ".github" / "pull_request_template.md").write_text("## Summary\n", encoding="utf-8") (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When the lane is published publish(lane, tmp_path) + # Then the body still has the criteria and says no report was written body = (tmp_path / "body.md").read_text(encoding="utf-8") assert "- the widget renders" in body assert "wrote no report" in body def test_says_when_the_lane_wrote_no_report(self, tmp_path: Path) -> None: + # Given a lane with a change and no report lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When it is published publish(lane, tmp_path) + # Then the body says no report was written assert "wrote no report" in (tmp_path / "body.md").read_text(encoding="utf-8") def test_a_lane_that_left_no_change_publishes_nothing(self, tmp_path: Path) -> None: """A lane that stopped on a bad task reverts its attempt; no pull request may open.""" + # Given a lane with a report but no change lane = claimed_lane(tmp_path) (lane / ".lane-report.md").write_text("Stopped: the task is wrong.\n", encoding="utf-8") + # When it is published result = publish(lane, tmp_path) + # Then nothing is pushed and gh is never called assert result.returncode == NOTHING assert result.stdout.startswith("NOTHING\ta-001") assert not (tmp_path / "gh.log").read_text(), "gh must not be called" def test_publishes_commits_the_lane_made_itself(self, tmp_path: Path) -> None: + # Given a lane that committed its own change lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") git(lane, "add", "widget.py") git(lane, "commit", "-q", "-m", "feat(a): add the widget") + # When it is published result = publish(lane, tmp_path) + # Then that commit is pushed as it is assert result.returncode == OK, result.stderr assert remote_subject(tmp_path) == "feat(a): add the widget" class TestCommitSubject: def test_uses_the_task_type(self, tmp_path: Path) -> None: + # Given a fix task lane = claimed_lane(tmp_path, title="Stop the loader dropping footnotes", task_type="fix") (lane / "loader.py").write_text("x = 1\n", encoding="utf-8") + # When its lane is published publish(lane, tmp_path) + # Then the subject is a fix with the title in lower case assert remote_subject(tmp_path) == "fix(a): stop the loader dropping footnotes" def test_leaves_a_leading_acronym_alone(self, tmp_path: Path) -> None: + # Given a title starting with an acronym lane = claimed_lane(tmp_path, title="DITA conrefs resolve") (lane / "loader.py").write_text("x = 1\n", encoding="utf-8") + # When its lane is published publish(lane, tmp_path) + # Then the acronym keeps its case assert remote_subject(tmp_path) == "feat(a): DITA conrefs resolve" class TestPullRequest: def test_a_rerun_pushes_without_opening_a_second_pull_request(self, tmp_path: Path) -> None: + # Given a lane whose pull request is already open lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When it is published again result = publish(lane, tmp_path, GH_EXISTING=URL) + # Then the open pull request is reported and no other is created assert result.stdout == f"PUBLISHED\ta-001\t{URL}\n" assert "pr create" not in (tmp_path / "gh.log").read_text() def test_a_rerun_opens_a_pull_request_that_is_missing(self, tmp_path: Path) -> None: """Re-running is the recovery path, so an already pushed lane must still publish.""" + # Given a lane already pushed but with no pull request lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") git(lane, "add", "widget.py") git(lane, "commit", "-q", "-m", "feat(a): add the widget") git(lane, "push", "-q", "origin", "HEAD:refs/heads/task/a-001") + # When it is published again result = publish(lane, tmp_path) + # Then the pull request is created assert result.stdout == f"PUBLISHED\ta-001\t{URL}\n" assert "pr create" in (tmp_path / "gh.log").read_text() def test_a_closed_pull_request_is_not_reused(self, tmp_path: Path) -> None: """A relaunched task needs a new pull request, not the one that was closed.""" + # Given a lane whose earlier pull request was closed lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When it is published result = publish(lane, tmp_path, GH_EXISTING="https://example.test/pr/2", GH_EXISTING_STATE="CLOSED") + # Then a new pull request is created assert result.stdout == f"PUBLISHED\ta-001\t{URL}\n" assert "pr create" in (tmp_path / "gh.log").read_text() def test_opens_the_pull_request_without_a_missing_label(self, tmp_path: Path) -> None: """The pull request is the point; a repository without the label must not block it.""" + # Given a repository without the ai-assisted label lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") + # When a lane is published result = publish(lane, tmp_path, GH_NO_LABEL="1") + # Then the pull request opens anyway, with a warning assert result.returncode == OK, result.stderr assert result.stdout == f"PUBLISHED\ta-001\t{URL}\n" assert "ai-assisted label could not be applied" in result.stderr def test_refuses_to_run_off_the_task_branch(self, tmp_path: Path) -> None: + # Given a lane switched to another branch lane = claimed_lane(tmp_path) git(lane, "checkout", "-q", "-b", "elsewhere") + # When it is published result = publish(lane, tmp_path) + # Then it refuses assert result.returncode == USAGE_ERROR assert "not on task/a-001" in result.stderr diff --git a/tests/unit/test_release_lane_script.py b/tests/unit/test_release_lane_script.py index 2a49ad9..8fd69d7 100644 --- a/tests/unit/test_release_lane_script.py +++ b/tests/unit/test_release_lane_script.py @@ -45,48 +45,61 @@ def remote_has_claim(tmp_path: Path) -> bool: class TestRelease: def test_an_untouched_lane_gives_its_task_back(self, tmp_path: Path) -> None: """A session that died at startup must not hold the task until someone notices.""" + # Given a claimed lane where nothing was done lane = claimed_lane(tmp_path) + # When it is released result = release(lane) + # Then the claim is gone from the remote assert result.returncode == RELEASED, result.stderr assert result.stdout == "RELEASED\ta-001\n" assert not remote_has_claim(tmp_path) def test_removes_the_worktree_and_local_branch(self, tmp_path: Path) -> None: """Otherwise the next launch finds the path taken and skips the task.""" + # Given a claimed lane where nothing was done lane = claimed_lane(tmp_path) + # When it is released release(lane) + # Then its worktree and local branch are gone too assert not lane.exists() assert "task/a-001" not in git(tmp_path / "dev", "branch", "--list").stdout def test_keeps_a_lane_with_uncommitted_work(self, tmp_path: Path) -> None: + # Given a lane with an uncommitted file lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('half done')\n", encoding="utf-8") + # When it is released result = release(lane) + # Then the claim and the work are kept assert result.returncode == KEPT assert "has changes" in result.stdout assert remote_has_claim(tmp_path) assert (lane / "widget.py").exists() def test_keeps_a_lane_that_committed(self, tmp_path: Path) -> None: + # Given a lane that committed past its claim lane = claimed_lane(tmp_path) (lane / "widget.py").write_text("print('widget')\n", encoding="utf-8") git(lane, "add", "widget.py") git(lane, "commit", "-q", "-m", "feat(a): add the widget") + # When it is released result = release(lane) + # Then the claim is kept assert result.returncode == KEPT assert "commits past the claim" in result.stdout assert remote_has_claim(tmp_path) def test_never_deletes_a_branch_that_moved_on(self, tmp_path: Path) -> None: """The remote is the truth: someone may have pushed to the task since.""" + # Given someone else pushed to the task branch since the claim lane = claimed_lane(tmp_path) other = tmp_path / "other" subprocess.run(["git", "clone", "-q", str(tmp_path / "origin.git"), str(other)], check=True) # noqa: S603, S607 @@ -96,17 +109,22 @@ def test_never_deletes_a_branch_that_moved_on(self, tmp_path: Path) -> None: git(other, "commit", "-q", "--allow-empty", "-m", "feat(a): someone else's work") git(other, "push", "-q", "origin", "task/a-001") + # When the lane is released result = release(lane) + # Then the remote branch is left alone assert result.returncode == KEPT assert "no longer this claim" in result.stdout assert remote_has_claim(tmp_path) def test_refuses_to_run_off_the_task_branch(self, tmp_path: Path) -> None: + # Given a lane switched to another branch lane = claimed_lane(tmp_path) git(lane, "checkout", "-q", "-b", "elsewhere") + # When it is released result = release(lane) + # Then it refuses, and the claim stays assert result.returncode == USAGE_ERROR assert remote_has_claim(tmp_path) diff --git a/tests/unit/test_where_script.py b/tests/unit/test_where_script.py index 0897487..1025609 100644 --- a/tests/unit/test_where_script.py +++ b/tests/unit/test_where_script.py @@ -145,52 +145,97 @@ class TestPhases: """Before lanes, the next action follows from which artifact is missing or unapproved.""" def test_an_empty_project_starts_with_requirements(self, tmp_path: Path) -> None: + # Given a repository with no delivery files repo = make_repo(tmp_path) + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then requirements are missing, and writing them is next assert result.returncode == OK assert ["requirements", "missing", "delivery/prd.md"] in rows(result.stdout, "PHASE") assert actions(result.stdout)[-1] == "write-requirements" def test_a_draft_prd_waits_for_approval(self, tmp_path: Path) -> None: + # Given a draft PRD repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "draft") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "approve-requirements" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then its approval is next + assert actions(result.stdout)[-1] == "approve-requirements" def test_an_unfilled_template_status_is_not_approved(self, tmp_path: Path) -> None: + # Given a PRD whose status is still the template's choice repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "draft | approved") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "approve-requirements" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then it still awaits approval + assert actions(result.stdout)[-1] == "approve-requirements" def test_a_draft_contract_holds_decomposition(self, tmp_path: Path) -> None: + # Given an approved PRD and a draft contract repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "approved") artifact(repo, "delivery/contracts/payload.md", "draft") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then the contract's approval is next, named assert rows(result.stdout, "NEXT")[-1] == ["approve-design", "awaiting approval: delivery/contracts/payload.md"] def test_a_proposed_decision_record_holds_decomposition(self, tmp_path: Path) -> None: + # Given an approved PRD and a proposed decision record repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "approved") artifact(repo, "docs/adr/ADR-001-x.md", "proposed") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "approve-design" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then design approval is next + assert actions(result.stdout)[-1] == "approve-design" def test_without_a_spine_the_choice_is_design_or_decompose(self, tmp_path: Path) -> None: + # Given an approved PRD and no design documents repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "approved") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "design-or-decompose" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then the choice between design and decomposition is next + assert actions(result.stdout)[-1] == "design-or-decompose" def test_with_an_agreed_spine_the_next_phase_is_decomposition(self, tmp_path: Path) -> None: + # Given an approved PRD and an agreed contract repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "approved") artifact(repo, "delivery/contracts/payload.md", "agreed") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "decompose" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then decomposition is next + assert actions(result.stdout)[-1] == "decompose" def test_a_draft_decomposition_waits_for_approval(self, tmp_path: Path) -> None: + # Given a draft decomposition with a task repo = make_repo(tmp_path) artifact(repo, "delivery/prd.md", "approved") artifact(repo, "delivery/decomposition.md", "draft") task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then its approval is next, and no task is reported yet assert actions(result.stdout)[-1] == "approve-decomposition" assert rows(result.stdout, "TASK") == [] @@ -199,83 +244,130 @@ class TestLanes: """Once decomposition is approved, the next action follows from derived task state.""" def test_ready_tasks_are_launched(self, tmp_path: Path) -> None: + # Given an approved plan with two ready tasks repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") task(repo, "a-002") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then starting both is next assert rows(result.stdout, "NEXT")[-1][0] == "start" assert "a-001 a-002" in rows(result.stdout, "NEXT")[-1][1] def test_a_serial_set_waits_for_the_task_in_flight(self, tmp_path: Path) -> None: + # Given a serial plan with its first task claimed repo = make_repo(tmp_path) approved_plan(repo, run_as="serially") task(repo, "a-001", alone=True) task(repo, "a-002", alone=True) claim(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then it waits for that task before the next assert rows(result.stdout, "NEXT")[-1] == ["wait", "a-001 runs alone and is in flight; then a-002"] def test_a_serial_set_with_nothing_in_flight_starts_one_task(self, tmp_path: Path) -> None: + # Given a serial plan with nothing claimed repo = make_repo(tmp_path) approved_plan(repo, run_as="serially") task(repo, "a-001", alone=True) task(repo, "a-002", alone=True) + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then only the first task is started action, detail = rows(result.stdout, "NEXT")[-1] assert action == "start" assert detail.startswith("alone, first of: a-001 a-002 (sh ") assert detail.endswith("launch-lanes.sh a-001)") def test_a_task_that_runs_alone_waits_for_work_in_flight(self, tmp_path: Path) -> None: + # Given a task that runs alone, and another in flight repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") task(repo, "a-002", alone=True) claim(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then it waits for the work in flight to merge assert rows(result.stdout, "NEXT")[-1] == ["wait", "a-002 runs alone, after in-flight work merges: a-001"] def test_shared_tasks_go_first_and_the_lone_one_is_named(self, tmp_path: Path) -> None: + # Given a ready task and one that runs alone repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") task(repo, "a-002", alone=True) + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then the shared task starts and the lone one is named for after action, detail = rows(result.stdout, "NEXT")[-1] assert action == "start" assert detail.startswith("launch lanes for: a-001 (sh ") assert detail.endswith("; after they merge, alone: a-002") def test_a_merged_task_with_its_branch_deleted_is_not_offered_again(self, tmp_path: Path) -> None: + # Given a-001 merged and its branch deleted, and a-002 depending on it repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") task(repo, "a-002", depends_on="a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then only a-002 is offered action, detail = rows(result.stdout, "NEXT")[-1] assert action == "start" assert detail.startswith("launch lanes for: a-002 (sh ") def test_every_task_merged_is_complete(self, tmp_path: Path) -> None: + # Given the only task merged repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") - assert actions(where(repo, fake_gh(tmp_path, merged=["a-001"])).stdout)[-1] == "complete" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then the project is complete + assert actions(result.stdout)[-1] == "complete" def test_blocked_with_nothing_in_flight_is_reported_as_stuck(self, tmp_path: Path) -> None: """A dependency that nobody holds and that is not ready cannot arise, except by a cycle.""" + # Given two tasks that depend on each other repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001", depends_on="a-002") task(repo, "a-002", depends_on="a-001") - assert actions(where(repo, fake_gh(tmp_path)).stdout)[-1] == "unblock" + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then unblocking is next + assert actions(result.stdout)[-1] == "unblock" def test_unreadable_pull_request_state_is_an_error_not_a_guess(self, tmp_path: Path) -> None: + # Given gh fails repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, failing=True)) + + # Then it errors rather than offering a start assert result.returncode == ERROR assert "NEXT\tstart" not in result.stdout @@ -297,44 +389,65 @@ class TestMilestones: """With milestones, merged work stops at a review instead of reading as complete.""" def test_the_current_milestone_runs_lanes_as_usual(self, tmp_path: Path) -> None: + # Given two milestones, with a ready task in the first repo = make_repo(tmp_path) milestone_plan(repo) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then the first is active and its task starts assert rows(result.stdout, "MILESTONE") == [["skeleton", "active", "a", "-"], ["team", "planned", "-", "-"]] assert actions(result.stdout)[-1] == "start" def test_a_merged_milestone_asks_for_its_review(self, tmp_path: Path) -> None: + # Given the first milestone's only task merged repo = make_repo(tmp_path) milestone_plan(repo) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then its review is next, with the coverage command action, detail = rows(result.stdout, "NEXT")[-1] assert action == "review-milestone" assert "write delivery/milestones/skeleton.md with delivery-decompose" in detail assert detail.endswith("delivery-status/scripts/coverage.sh --milestone skeleton)") def test_a_draft_review_awaits_approval(self, tmp_path: Path) -> None: + # Given a merged milestone with a draft review repo = make_repo(tmp_path) milestone_plan(repo) task(repo, "a-001") artifact(repo, "delivery/milestones/skeleton.md", "draft") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then the review's approval is next assert rows(result.stdout, "NEXT")[-1] == [ "approve-milestone-review", "delivery/milestones/skeleton.md awaits human approval", ] def test_an_approved_review_asks_for_the_next_cut(self, tmp_path: Path) -> None: + # Given a merged milestone with an approved review repo = make_repo(tmp_path) milestone_plan(repo) task(repo, "a-001") artifact(repo, "delivery/milestones/skeleton.md", "approved") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then cutting the next milestone is next assert actions(result.stdout)[-1] == "decompose-milestone" assert rows(result.stdout, "NEXT")[-1][1].startswith("team: ") def test_complete_once_the_last_review_is_approved(self, tmp_path: Path) -> None: + # Given both milestones merged and only the first reviewed repo = make_repo(tmp_path) milestone_plan(repo, second_epics="b") task(repo, "a-001") @@ -346,15 +459,30 @@ def test_complete_once_the_last_review_is_approved(self, tmp_path: Path) -> None ) artifact(repo, "delivery/milestones/skeleton.md", "approved") merged = fake_gh(tmp_path, merged=["a-001", "b-001"]) - assert actions(where(repo, merged).stdout)[-1] == "review-milestone" + + # When where.sh runs + result = where(repo, merged) + + # Then the second milestone's review is next + assert actions(result.stdout)[-1] == "review-milestone" + + # When that review is approved too artifact(repo, "delivery/milestones/team.md", "approved") - assert actions(where(repo, merged).stdout)[-1] == "complete" + result = where(repo, merged) + + # Then the project is complete + assert actions(result.stdout)[-1] == "complete" def test_a_plan_without_milestones_still_completes(self, tmp_path: Path) -> None: + # Given a plan without milestones, its task merged repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, merged=["a-001"])) + + # Then no milestone is reported and the project is complete assert rows(result.stdout, "MILESTONE") == [] assert actions(result.stdout)[-1] == "complete" @@ -382,15 +510,20 @@ class TestInstallLayout: """The kit's skills find each other as siblings, so a split install is the first thing to fix.""" def test_a_split_install_is_reported_before_anything_else(self, tmp_path: Path) -> None: + # Given a ready task, and an install split between two skill directories repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") script = split_install(tmp_path) env = os.environ.copy() env["PATH"] = f"{fake_gh(tmp_path)}{os.pathsep}{env['PATH']}" + + # When where.sh runs from the split install result = subprocess.run( # noqa: S603 [SH, str(script), "--no-fetch"], cwd=repo, env=env, capture_output=True, text=True, check=False ) + + # Then repairing the install is the only next action assert result.returncode == OK, result.stderr assert actions(result.stdout) == ["repair-install"] detail = rows(result.stdout, "NEXT")[0][1] @@ -398,12 +531,15 @@ def test_a_split_install_is_reported_before_anything_else(self, tmp_path: Path) assert rows(result.stdout, "PHASE") == [] def test_a_missing_delivery_refine_is_reported(self, tmp_path: Path) -> None: + # Given an install without delivery-refine repo = make_repo(tmp_path) skills = tmp_path / "skills" shutil.copytree(SCRIPTS.parents[1], skills) shutil.rmtree(skills / "delivery-refine") env = os.environ.copy() env["PATH"] = f"{fake_gh(tmp_path)}{os.pathsep}{env['PATH']}" + + # When where.sh runs from it result = subprocess.run( # noqa: S603 [SH, str(skills / "delivery-orchestrator" / "scripts" / "where.sh"), "--no-fetch"], cwd=repo, @@ -412,6 +548,8 @@ def test_a_missing_delivery_refine_is_reported(self, tmp_path: Path) -> None: text=True, check=False, ) + + # Then repairing the install is next, naming the missing skill assert actions(result.stdout) == ["repair-install"] assert rows(result.stdout, "NEXT")[0][1].endswith( ": delivery-refine. The kit installs whole: remove every delivery-* skill directory, " @@ -419,8 +557,14 @@ def test_a_missing_delivery_refine_is_reported(self, tmp_path: Path) -> None: ) def test_a_whole_install_is_not_reported(self, tmp_path: Path) -> None: + # Given the kit installed whole repo = make_repo(tmp_path) - assert "repair-install" not in actions(where(repo, fake_gh(tmp_path)).stdout) + + # When where.sh runs + result = where(repo, fake_gh(tmp_path)) + + # Then no repair is asked for + assert "repair-install" not in actions(result.stdout) LENSES = """\ @@ -435,15 +579,21 @@ class TestReview: """Each pull request in review gets one REVIEW row per lens, naming the skill to run.""" def test_without_a_lens_file_only_the_general_review_is_suggested(self, tmp_path: Path) -> None: + # Given a pull request in review and no lens file repo = make_repo(tmp_path) approved_plan(repo) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, in_review={"a-001": PullRequest(7, ["src/app.py"])})) + + # Then only the general review is suggested assert result.returncode == OK assert rows(result.stdout, "REVIEW") == [["a-001", "#7", "general", "-", "default"]] assert ["review", "pull requests await review, one pass per REVIEW row: a-001"] in rows(result.stdout, "NEXT") def test_lenses_come_from_the_task_the_labels_and_the_paths(self, tmp_path: Path) -> None: + # Given a lens file, a task asking for security, and pull requests with labels and paths repo = make_repo(tmp_path) approved_plan(repo) write(repo, "delivery/review-lenses.conf", LENSES) @@ -453,7 +603,12 @@ def test_lenses_come_from_the_task_the_labels_and_the_paths(self, tmp_path: Path "a-001": PullRequest(7, ["src/auth/login.py"], ["ai-assisted"]), "a-002": PullRequest(8, ["infra/main.tf"], ["review:data"]), } - assert rows(where(repo, fake_gh(tmp_path, in_review=prs)).stdout, "REVIEW") == [ + + # When where.sh runs + result = where(repo, fake_gh(tmp_path, in_review=prs)) + + # Then each lens is suggested with what brought it in + assert rows(result.stdout, "REVIEW") == [ ["a-001", "#7", "general", "code-review", "default"], ["a-001", "#7", "security", "security-review", "task,path:src/auth/login.py"], ["a-002", "#8", "general", "code-review", "default"], @@ -463,19 +618,29 @@ def test_lenses_come_from_the_task_the_labels_and_the_paths(self, tmp_path: Path def test_a_pattern_is_matched_against_the_diff_not_expanded_in_the_checkout(self, tmp_path: Path) -> None: """README.md sits in the checkout; a globbed *.md would match only that and miss the diff.""" + # Given a lens on *.md and a pull request changing a nested markdown file repo = make_repo(tmp_path) approved_plan(repo) write(repo, "delivery/review-lenses.conf", LENSES) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path, in_review={"a-001": PullRequest(7, ["docs/guide/usage.md"])})) + + # Then the lens matches that file assert ["a-001", "#7", "docs", "-", "path:docs/guide/usage.md"] in rows(result.stdout, "REVIEW") def test_nothing_in_review_prints_no_review_rows(self, tmp_path: Path) -> None: + # Given a lens file and nothing in review repo = make_repo(tmp_path) approved_plan(repo) write(repo, "delivery/review-lenses.conf", LENSES) task(repo, "a-001") + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then no review is suggested assert rows(result.stdout, "REVIEW") == [] assert "review" not in actions(result.stdout) @@ -484,51 +649,83 @@ class TestRouting: """The AGENTS.md block is what sends a project's requests to the orchestrator.""" def test_a_missing_block_is_the_first_next_action(self, tmp_path: Path) -> None: + # Given a repository without the routing block repo = make_repo(tmp_path) + + # When where.sh runs result = where(repo, fake_gh(tmp_path)) + + # Then installing it is the first next action assert rows(result.stdout, "ROUTING") == [["missing"]] assert actions(result.stdout)[0] == "install-routing" assert "install-routing.sh" in rows(result.stdout, "NEXT")[0][1] def test_install_creates_agents_md_and_where_then_sees_it(self, tmp_path: Path) -> None: + # Given a repository without AGENTS.md repo = make_repo(tmp_path) + + # When the routing is installed result = subprocess.run([SH, str(INSTALL)], cwd=repo, capture_output=True, text=True, check=False) # noqa: S603 + + # Then AGENTS.md and the lens file are added assert result.stdout == "ADDED\tAGENTS.md\nADDED\tdelivery/review-lenses.conf\n" assert "delivery-orchestrator" in (repo / "AGENTS.md").read_text(encoding="utf-8") + + # When where.sh runs afterwards after = where(repo, fake_gh(tmp_path)) + + # Then it sees the routing and asks for nothing assert rows(after.stdout, "ROUTING") == [["present"]] assert "install-routing" not in actions(after.stdout) def test_install_appends_after_existing_content_once(self, tmp_path: Path) -> None: + # Given an AGENTS.md with its own content repo = make_repo(tmp_path) (repo / "AGENTS.md").write_text("# Rules\n\nKeep it tidy.", encoding="utf-8") + + # When the routing is installed twice for _ in range(2): subprocess.run([SH, str(INSTALL)], cwd=repo, capture_output=True, text=True, check=True) # noqa: S603 + + # Then the block follows the content, once text = (repo / "AGENTS.md").read_text(encoding="utf-8") assert text.startswith("# Rules\n\nKeep it tidy.\n\n\n") assert text.count("") == 1 def test_install_seeds_review_lenses_that_where_reads(self, tmp_path: Path) -> None: + # Given the routing installed, with its seeded lens file repo = make_repo(tmp_path) subprocess.run([SH, str(INSTALL)], cwd=repo, capture_output=True, text=True, check=True) # noqa: S603 approved_plan(repo) task(repo, "a-001") + + # When where.sh runs with a pull request in review result = where(repo, fake_gh(tmp_path, in_review={"a-001": PullRequest(7, ["src/auth/login.py"])})) + + # Then the seeded lens file is read assert rows(result.stdout, "REVIEW") == [["a-001", "#7", "general", "-", "default"]] def test_install_leaves_an_edited_lens_file_alone(self, tmp_path: Path) -> None: + # Given an edited lens file repo = make_repo(tmp_path) write(repo, "delivery/review-lenses.conf", LENSES) + + # When the routing is installed result = subprocess.run([SH, str(INSTALL)], cwd=repo, capture_output=True, text=True, check=True) # noqa: S603 + + # Then the lens file is reported present and left as it was assert "PRESENT\tdelivery/review-lenses.conf" in result.stdout assert (repo / "delivery/review-lenses.conf").read_text(encoding="utf-8") == LENSES @pytest.mark.parametrize("script", [WHERE, INSTALL]) def test_refuses_to_run_below_the_repository_root(self, tmp_path: Path, script: Path) -> None: + # Given a subdirectory of the repository repo = make_repo(tmp_path) (repo / "src").mkdir() env = os.environ.copy() env["PATH"] = f"{fake_gh(tmp_path)}{os.pathsep}{env['PATH']}" + + # When the script runs there result = subprocess.run( # noqa: S603 [SH, str(script)], cwd=repo / "src", @@ -537,5 +734,7 @@ def test_refuses_to_run_below_the_repository_root(self, tmp_path: Path, script: text=True, check=False, ) + + # Then it refuses, asking for the root assert result.returncode == ERROR assert "repository root" in result.stderr