@jesdi/skills 0.0.30 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +215 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +534 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +108 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +11 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/pr-writer.md +31 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +50 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +221 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +604 -0
- package/skills/implement-spec/tests/test_walk.py +459 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""In-process end-of-run tests: the rig and the scenarios of test_final.py without the launcher, so coverage sees
|
|
2
|
+
the driver, plus the cases that only a direct call or a look between two runs reaches."""
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
|
|
9
|
+
import jsonschema
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import driver
|
|
13
|
+
import driver_final
|
|
14
|
+
import driver_merge
|
|
15
|
+
import driver_report
|
|
16
|
+
import driver_state
|
|
17
|
+
import driver_summary
|
|
18
|
+
import driver_tidy
|
|
19
|
+
import test_final
|
|
20
|
+
from test_cli import git
|
|
21
|
+
from test_final import FILES, FINAL_MODELS, RESOLVE, THREE, TITLES, final, fixer, fn, ref, stop_lines
|
|
22
|
+
from test_fixloop import inside # noqa: F401 (the autouse fixture puts the rig's CLIs on PATH)
|
|
23
|
+
from test_review import FABLE, ONE, finding
|
|
24
|
+
from test_stops import UNATTENDED, decisions, stage
|
|
25
|
+
from test_walk import plan, tw
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Inside(test_final.Final):
|
|
29
|
+
"""The rig of test_final with the driver called in this process."""
|
|
30
|
+
|
|
31
|
+
def run(self, steps, *extra, parallel="2", watch=False):
|
|
32
|
+
(self.rig / "plan.json").write_text(json.dumps(steps))
|
|
33
|
+
args = ["--tickets", ".agent/tickets", "--gate", str(self.rig / "gate"), "--spec", "specs/csv/spec.md",
|
|
34
|
+
"--models", str(self.models), "--runner", str(self.rig / "run-agent"), "--parallel", parallel, *extra]
|
|
35
|
+
here, out = os.getcwd(), io.StringIO()
|
|
36
|
+
os.chdir(self.repo)
|
|
37
|
+
try:
|
|
38
|
+
with contextlib.redirect_stdout(out):
|
|
39
|
+
self.code = driver.main(args)
|
|
40
|
+
finally:
|
|
41
|
+
os.chdir(here)
|
|
42
|
+
self.out, self.err = out.getvalue(), ""
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def final(self):
|
|
46
|
+
return self.state()["run"]["final"]
|
|
47
|
+
|
|
48
|
+
def order(self):
|
|
49
|
+
"""The ticket numbers in merge order: two tickets that run at once merge in either order."""
|
|
50
|
+
return [number for number, _ in self.state()["run"]["landed"]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(autouse=True)
|
|
54
|
+
def tools(monkeypatch, tmp_path):
|
|
55
|
+
"""The fake `gh` of the rig is on PATH, and no git call of an agent opens an editor."""
|
|
56
|
+
monkeypatch.setenv("PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}")
|
|
57
|
+
monkeypatch.setenv("GIT_EDITOR", "true")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stray(rig, text="by hand"):
|
|
61
|
+
"""A commit on the PR branch that no agent of the run made."""
|
|
62
|
+
(rig.repo / "stray.txt").write_text(text)
|
|
63
|
+
git(rig.repo, "add", "-A")
|
|
64
|
+
git(rig.repo, "commit", "-q", "-m", text)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------- the story: local stop, rerun, push
|
|
68
|
+
|
|
69
|
+
def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the_pr_once(tmp_path, pr_skill):
|
|
70
|
+
rig = Inside(tmp_path, THREE, FINAL_MODELS)
|
|
71
|
+
rig.run(plan())
|
|
72
|
+
assert rig.code == 0, rig.out + "\n".join(rig.ledger())
|
|
73
|
+
tip = rig.branch()
|
|
74
|
+
assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv"), "nothing is pushed"
|
|
75
|
+
shown_tip, reviewed, push = stop_lines(rig.out)
|
|
76
|
+
assert (shown_tip.group(1), reviewed.group(1)) == (tip, tip) and push.group(0).endswith("--parallel 2 --push")
|
|
77
|
+
rows, order = rig.commits(rig.start), rig.order()
|
|
78
|
+
assert [(r["subject"], r["body"].strip()) for r in rows] == [
|
|
79
|
+
(f"feat({nn}): {TITLES[nn]}", f"Ticket: {FILES[nn]}") for nn in order] and order[2] == "02"
|
|
80
|
+
assert len(rig.commits(rig.start, rig.recovery())) == 6
|
|
81
|
+
assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
|
|
82
|
+
assert rig.led("Recovery point: feat/csv was at " + rig.recovery(), ref(rig.repo, "feat/csv^{tree}"))
|
|
83
|
+
assert rig.led("End of run: final-reviewer ran on", FABLE) and rig.led("End of run: the gate is green on " + tip[:7])
|
|
84
|
+
assert {nn: e["tip"] for nn, e in rig.state()["tickets"].items()} == {nn: r["sha"] for nn, r in zip(order, rows)}
|
|
85
|
+
assert f"The final review covers feat/csv up to {tip[:7]}. Nothing is pushed." in rig.out
|
|
86
|
+
assert git(rig.repo, "symbolic-ref", "--short", "HEAD").strip() == "feat/csv" and git(rig.repo, "status", "--short") == ""
|
|
87
|
+
calls, gates, ledger = len(rig.calls()), len(rig.gates()), rig.ledger()
|
|
88
|
+
|
|
89
|
+
rig.run(plan())
|
|
90
|
+
assert (rig.code, len(rig.calls()), len(rig.gates()), rig.branch(), rig.ledger()) == (0, calls, gates, tip, ledger)
|
|
91
|
+
assert all(stop_lines(rig.out)) and rig.pushes() == []
|
|
92
|
+
|
|
93
|
+
rig.run(plan(), "--push")
|
|
94
|
+
assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {tip}"], rig.out
|
|
95
|
+
(pr,) = rig.prs()
|
|
96
|
+
assert pr["argv"][:10] == ["pr", "create", "--base", "main", "--head", "feat/csv", "--title", "csv spec",
|
|
97
|
+
"--body-file", ".agent/implement/final/pr-body.md"]
|
|
98
|
+
assert pr["body"].startswith("## Summary\n\nCSV export.\n\n## Tickets\n"), "the pr-writer's body, then the facts"
|
|
99
|
+
assert [f"- {nn}: {row['sha'][:7]} {TITLES[nn]}" for nn, row in zip(order, rows)] == pr["body"].splitlines()[8:11]
|
|
100
|
+
(writer,) = rig.final_calls("pr-writer")
|
|
101
|
+
assert f"PR method: {pr_skill}\n" in writer["prompt"]
|
|
102
|
+
assert "Facts of the run, which the driver puts after your body:\n## Tickets" in writer["prompt"]
|
|
103
|
+
assert "## Rulings\n\nNone.\n" in pr["body"] and "Scoped review after the fix batch: not needed" in pr["body"]
|
|
104
|
+
assert f"Pushed feat/csv at {tip} to origin." in rig.out and "PR: https://github.example/pull/1" in rig.out
|
|
105
|
+
assert f"feat/csv is pushed at {tip[:7]}; its PR is https://github.example/pull/1." in rig.out
|
|
106
|
+
assert rig.led("The PR of feat/csv into main is open: https://github.example/pull/1.")
|
|
107
|
+
|
|
108
|
+
rig.run(plan(), "--push")
|
|
109
|
+
assert rig.code == 0 and len(rig.pushes()) == 1 and len(rig.prs()) == 1, "a second push rerun sends nothing"
|
|
110
|
+
assert (len(rig.calls()), len(rig.gates())) == (calls + 1, gates), "the one agent of the pushes is the pr-writer"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_the_final_review_does_not_start_while_a_ticket_is_not_merged_and_the_ledger_says_so_once(tmp_path):
|
|
114
|
+
rig = Inside(tmp_path, THREE, gate={"red": ["ticket/01"]})
|
|
115
|
+
rig.run(plan())
|
|
116
|
+
rig.run(plan(), *UNATTENDED)
|
|
117
|
+
assert rig.code == 1 and not rig.final_calls("final-reviewer") and not rig.recovery()
|
|
118
|
+
assert rig.led("End-of-run work skipped") == ["End-of-run work skipped: these tickets are not merged: 01, 02."]
|
|
119
|
+
assert "The end-of-run work (rebase, tidy, final review, push) did not run: these tickets are not merged: 01, 02." in rig.out
|
|
120
|
+
assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["01"]}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_a_run_that_was_stopped_before_the_branch_moved_finishes_the_move_and_keeps_every_ticket_merged(tmp_path):
|
|
124
|
+
rig = Inside(tmp_path, THREE)
|
|
125
|
+
rig.run(plan())
|
|
126
|
+
tip, calls = rig.branch(), len(rig.calls())
|
|
127
|
+
git(rig.repo, "reset", "-q", "--hard", rig.recovery()) # as if the run had ended right before the move
|
|
128
|
+
rig.run(plan())
|
|
129
|
+
assert (rig.code, rig.branch(), len(rig.calls())) == (0, tip, calls), rig.out
|
|
130
|
+
assert not rig.led("returned to implement") and git(rig.repo, "status", "--short") == ""
|
|
131
|
+
assert {e["phase"] for e in rig.state()["tickets"].values()} == {"merged"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_branch_that_cannot_move_to_the_tidied_tip_keeps_the_recorded_ticket_tips(tmp_path, monkeypatch):
|
|
135
|
+
rig = Inside(tmp_path, ONE)
|
|
136
|
+
real = driver_merge.land # the merge of the ticket moves the branch; the move to the tidied tip fails
|
|
137
|
+
monkeypatch.setattr(driver_merge, "land", lambda pr, new, old: (
|
|
138
|
+
"the ref moved" if driver_tidy.text(new).startswith("feat(01)") else real(pr, new, old)))
|
|
139
|
+
rig.run(plan())
|
|
140
|
+
assert rig.code == 1, rig.out
|
|
141
|
+
(line,) = rig.led("End of run: BLOCKED at the rewrite: cannot move feat/csv to the tidied tip")
|
|
142
|
+
assert line.endswith(f"the ref moved; feat/csv is unrewritten at its recovery point {rig.recovery()[:7]}.")
|
|
143
|
+
assert rig.branch() == rig.recovery() == rig.state()["tickets"]["01"]["tip"] and not rig.final().get("tidied")
|
|
144
|
+
assert not rig.final_calls("final-reviewer") and "End of run: BLOCKED at the rewrite" in rig.out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_a_tidied_tip_that_left_the_branch_stops_the_end_of_run(tmp_path):
|
|
148
|
+
rig = Inside(tmp_path, ONE)
|
|
149
|
+
rig.run(plan())
|
|
150
|
+
state = rig.state()
|
|
151
|
+
state["run"]["final"]["tidied"] = rig.recovery() # the history before the tidy is not in the branch
|
|
152
|
+
(rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
153
|
+
rig.run(plan())
|
|
154
|
+
assert rig.code == 1 and rig.led("End of run: BLOCKED at feat/csv does not hold the tidied tip " + rig.recovery()[:7])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_a_ticket_whose_merge_left_the_tree_as_it_was_gets_the_commit_before_it(tmp_path):
|
|
158
|
+
rig = Inside(tmp_path, ONE)
|
|
159
|
+
rig.run(plan(), "--dry-run")
|
|
160
|
+
os.chdir(rig.repo)
|
|
161
|
+
start = ref(rig.repo, "HEAD")
|
|
162
|
+
stray(rig)
|
|
163
|
+
tip = ref(rig.repo, "HEAD")
|
|
164
|
+
tickets = {nn: driver.driver_tickets.Ticket(nn, f"{nn}-t.md", f"Title {nn}", None, None, None, None, None, [], {})
|
|
165
|
+
for nn in ("01", "02", "03")}
|
|
166
|
+
commits = driver_tidy.tidy(start, [["01", start], ["02", tip], ["03", tip]], tickets)
|
|
167
|
+
assert commits["01"] == start and commits["02"] == commits["03"] != tip
|
|
168
|
+
assert driver_tidy.same_tree(commits["03"], tip) and not driver_tidy.same_tree("0" * 40, "0" * 40)
|
|
169
|
+
assert driver_tidy.text(commits["02"]) == "feat(02): Title 02\n\nTicket: 02-t.md\n\n"
|
|
170
|
+
assert git(rig.repo, "log", "-1", "--format=%an %ae", commits["02"]) == "t t@example.com\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- the fix batch and its report
|
|
174
|
+
|
|
175
|
+
def test_a_fix_batch_is_named_per_commit_and_a_commit_with_no_owner_is_a_violation(tmp_path):
|
|
176
|
+
"""The first report leaves out one of two commits; the rerun names both, one with a ticket that is not there
|
|
177
|
+
in a third attempt that never runs."""
|
|
178
|
+
rig = Inside(tmp_path, THREE)
|
|
179
|
+
first = fixer(fn("02"), "quote commas", "escape quotes")
|
|
180
|
+
first["report"]["commits"].pop()
|
|
181
|
+
retry = {"report": dict(first["report"], commits=[{"sha": "$HEAD~1", "ticket": "02"}, {"sha": "$HEAD", "ticket": "01"}])}
|
|
182
|
+
retry["files"] = {"wip.txt": "not committed\n"}
|
|
183
|
+
rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE"), finding("Q-MINOR", "minor")), final()],
|
|
184
|
+
"final/fixer": [first, retry]}))
|
|
185
|
+
assert rig.code == 1, rig.out
|
|
186
|
+
assert rig.led("End of run: fixer ran on", "is on feat/csv and not in `commits`: each commit of the batch needs its ticket")
|
|
187
|
+
assert rig.led("End of run: BLOCKED at the fix batch: commits.0.sha") # `$HEAD~1` is no SHA: a second violation
|
|
188
|
+
reviewed = rig.final()["reviewed"]
|
|
189
|
+
assert rig.branch() == reviewed, "the rejected commits are off the branch"
|
|
190
|
+
(line,) = rig.led(f"End of run: the fix batch was rejected, so the driver moved feat/csv back to its reviewed tip {reviewed[:7]}")
|
|
191
|
+
left = line.split("the commits of the batch end at ")[1].split()[0]
|
|
192
|
+
assert git(rig.repo, "log", "--format=%s", f"{reviewed}..{left}").split("\n")[:2] == ["escape quotes", "quote commas"]
|
|
193
|
+
assert line.endswith("The files of the fixer stay in slot 1 as they are.")
|
|
194
|
+
assert (rig.repo / "wip.txt").read_text() == "not committed\n" and "# escape quotes" in (rig.repo / "demo/export.py").read_text()
|
|
195
|
+
prompt = rig.final_calls("fixer")[0]["prompt"]
|
|
196
|
+
assert "- important: Q-ONE (demo/export.py:99)\n" in prompt and "Q-MINOR" not in prompt
|
|
197
|
+
assert ".agent/implement/final/findings-0.json" in prompt and prompt.rstrip().endswith("one ref store.")
|
|
198
|
+
assert "Locked files: tests/test_01.py tests/test_02.py tests/test_03.py\n" in prompt
|
|
199
|
+
|
|
200
|
+
rig.run(plan(), "--push") # as after any committing role that failed: the work in slot 1 stops the rerun
|
|
201
|
+
assert rig.code == 3 and "uncommitted change: wip.txt" in rig.out and rig.pushes() == []
|
|
202
|
+
git(rig.repo, "checkout", "-q", "-f", "feat/csv") # the operator discards the work
|
|
203
|
+
(rig.repo / "wip.txt").unlink()
|
|
204
|
+
|
|
205
|
+
stray(rig, "fix(99): by hand") # a commit with the name of a fix commit and no validated owner
|
|
206
|
+
rig.run(plan(), "--push")
|
|
207
|
+
assert rig.code == 1 and rig.pushes() == [] and len(rig.final_calls("final-reviewer")) == 1, rig.out
|
|
208
|
+
assert rig.led("End of run: BLOCKED at the tip", f"is not a tip the driver made: only {reviewed[:7]} gets a review")
|
|
209
|
+
|
|
210
|
+
git(rig.repo, "reset", "-q", "--hard", reviewed)
|
|
211
|
+
rig.run(plan(**{"final/fixer": [{}, {}, fixer(fn("02"), "quote commas")]}), "--push")
|
|
212
|
+
assert rig.code == 0 and [r["subject"] for r in rig.commits(reviewed)] == ["fix(02): quote commas"], rig.out
|
|
213
|
+
assert rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and rig.final()["batch"] == rig.branch()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_a_fixer_that_times_out_twice_keeps_its_files_and_the_branch_stays_at_the_reviewed_tip(tmp_path):
|
|
217
|
+
rig = Inside(tmp_path, ONE)
|
|
218
|
+
slow = {"files": {"wip.txt": "half done\n"}, "sleep": 30}
|
|
219
|
+
rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE"))], "final/fixer": [slow, slow]}),
|
|
220
|
+
"--timeout", "fixer=0.01", *UNATTENDED)
|
|
221
|
+
reviewed = rig.final()["reviewed"]
|
|
222
|
+
assert rig.code == 1 and rig.pushes() == [] and rig.branch() == reviewed, rig.out
|
|
223
|
+
assert (rig.repo / "wip.txt").read_text() == "half done\n"
|
|
224
|
+
assert rig.led(f"End of run: the fix batch was rejected, so the driver moved feat/csv back to its reviewed tip "
|
|
225
|
+
f"{reviewed[:7]}; the fixer made no commit. The files of the fixer stay in slot 1 as they are.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_a_fix_commit_for_a_ticket_that_is_not_in_the_run_is_a_violation(tmp_path):
|
|
229
|
+
rig = Inside(tmp_path, ONE)
|
|
230
|
+
rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE")), final()],
|
|
231
|
+
"final/fixer": [fixer(fn("01"), "fix(01): named already", ticket_of="09"),
|
|
232
|
+
{"report": dict(fixer(fn("01"), "x")["report"], commits=[{"sha": "$HEAD", "ticket": "01"}])}]}))
|
|
233
|
+
assert rig.code == 0, rig.out
|
|
234
|
+
assert rig.led("End of run: fixer ran on", ": 09 is not a ticket of this run")
|
|
235
|
+
(row,) = rig.commits(rig.final()["tidied"])
|
|
236
|
+
assert row["subject"] == "fix(01): named already" and rig.final()["reviewed"] == row["sha"] == rig.branch()
|
|
237
|
+
assert rig.led("Fix batch of the final review: 1 commit on", f"{row['sha'][:7]} fix(01): named already.")
|
|
238
|
+
scoped = rig.final_calls("final-reviewer")[1]["prompt"]
|
|
239
|
+
assert f"Range: {rig.final()['tidied']}..{row['sha']}\n" in scoped and "This is the scoped review after the fix batch" in scoped
|
|
240
|
+
assert scoped.rstrip().endswith("one ref store.") and ".agent/implement/final/findings-0.json" in scoped
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_a_final_reviewer_that_changes_the_slot_is_restored_and_runs_once_more(tmp_path):
|
|
244
|
+
rig = Inside(tmp_path, ONE)
|
|
245
|
+
dirty = final(finding("DROPPED"), files={"left.txt": "x"})
|
|
246
|
+
moved_head = final(files={"more.txt": "x"}, commit="by the reviewer")
|
|
247
|
+
rig.run(plan(**{"final/final-reviewer": [dirty, final(finding("KEPT", "minor"))]}))
|
|
248
|
+
assert rig.code == 0 and len(rig.final_calls("final-reviewer")) == 2 and not (rig.repo / "left.txt").exists(), rig.out
|
|
249
|
+
assert rig.led("End of run: a read-only role broke its rule: the tree is not as the review found it: left.txt; the "
|
|
250
|
+
f"driver restored slot 1 to {rig.branch()[:7]} and runs the final review again.")
|
|
251
|
+
assert rig.led("KEPT") and not rig.led("DROPPED") and rig.final()["reviewed"] == rig.branch()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_a_final_reviewer_that_changes_the_slot_twice_ends_the_run_with_a_clean_slot(tmp_path):
|
|
255
|
+
rig = Inside(tmp_path, ONE)
|
|
256
|
+
moved_head = final(files={"more.txt": "x"}, commit="by the reviewer")
|
|
257
|
+
rig.run(plan(**{"final/final-reviewer": [final(files={"left.txt": "x"}), moved_head]}), *UNATTENDED)
|
|
258
|
+
tip = rig.final()["tidied"]
|
|
259
|
+
assert rig.code == 1 and rig.final()["reviewed"] is None and rig.branch() == tip, rig.out
|
|
260
|
+
assert rig.led(f"End of run: BLOCKED at the final review: the slot is no longer on feat/csv at {tip[:7]}.")
|
|
261
|
+
assert git(rig.repo, "status", "--short") == "" and git(rig.repo, "symbolic-ref", "--short", "HEAD") == "feat/csv\n"
|
|
262
|
+
rig.run(plan(), *UNATTENDED) # the rerun is not stopped by what the reviewer left
|
|
263
|
+
assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {tip}"], rig.out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_a_red_gate_on_the_tidied_tip_stops_before_the_final_review(tmp_path):
|
|
267
|
+
rig = Inside(tmp_path, ONE, gate={"red": ["feat/csv"]})
|
|
268
|
+
rig.run(plan(), *UNATTENDED)
|
|
269
|
+
assert rig.code == 1 and not rig.final_calls("final-reviewer") and rig.pushes() == [], rig.out
|
|
270
|
+
assert rig.led("End of run: BLOCKED at the gate: the gate is red on " + rig.branch()[:7])
|
|
271
|
+
assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
|
|
272
|
+
assert rig.state()["run"]["gates"] == 2 and "| gates run | 2 |" in rig.out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------- the triage after the second round
|
|
276
|
+
|
|
277
|
+
def parked(tmp_path, *findings):
|
|
278
|
+
rig = Inside(tmp_path, ONE)
|
|
279
|
+
steps = plan(**{"final/final-reviewer": [final(*findings), final(*findings)],
|
|
280
|
+
"final/fixer": [fixer(fn("01"), "first try", ticket_of="01")]})
|
|
281
|
+
return rig.run(steps), steps
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_open_findings_after_the_second_round_park_the_run_on_one_question_each(tmp_path):
|
|
285
|
+
rig, steps = parked(tmp_path, finding("STILL-OPEN"), finding("ALSO"))
|
|
286
|
+
assert rig.code == 2 and rig.pushes() == [], rig.out
|
|
287
|
+
first, second = decisions(rig)["questions"]
|
|
288
|
+
assert (first["id"], second["id"]) == ("final-finding-1", "final-finding-2")
|
|
289
|
+
assert first["title"] == "Final review: a finding is open after the second review round: STILL-OPEN (demo/export.py:99)"
|
|
290
|
+
assert [o["id"] for o in first["options"]] == ["fix", "accept"] and first["recommended"] == "fix"
|
|
291
|
+
assert first["evidence"] == [".agent/implement/final/findings-1.json", ".agent/implement/final/final-reviewer-1.json"]
|
|
292
|
+
assert "Question final-finding-1: " in rig.out and rig.state()["run"]["stops"] == 1
|
|
293
|
+
assert rig.led("End of run: the operator decides on 2 questions of kind final-finding")
|
|
294
|
+
assert len(rig.final_calls("final-reviewer")) == 2 and len(rig.final_calls("fixer")) == 1
|
|
295
|
+
|
|
296
|
+
rig.run(steps, "--answer", "final-finding-1=accept")
|
|
297
|
+
assert rig.code == 2 and rig.led("Ruling: final-review finding closed as it is (accept): STILL-OPEN (demo/export.py:99).")
|
|
298
|
+
assert [q["id"] for q in decisions(rig)["questions"]] == ["final-finding-2"] and not any(stop_lines(rig.out))
|
|
299
|
+
|
|
300
|
+
rig.run(steps, "--answer", "final-finding-2=accept", "--push")
|
|
301
|
+
assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"], rig.out
|
|
302
|
+
body = rig.prs()[0]["body"]
|
|
303
|
+
assert "- Ruling: final-review finding closed as it is (accept): ALSO (demo/export.py:99)." in body
|
|
304
|
+
assert f"- {rig.branch()[:7]} fix(01): first try" in body
|
|
305
|
+
assert f"Scoped review after the fix batch: {rig.final()['tidied']}..{rig.branch()}" in body
|
|
306
|
+
assert len(rig.final_calls("final-reviewer")) == 2, "no third round"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_the_answer_fix_keeps_the_finding_open_and_nothing_is_pushed(tmp_path):
|
|
310
|
+
rig, steps = parked(tmp_path, finding("STILL-OPEN"))
|
|
311
|
+
rig.run(steps, "--answer", "final-finding-1=fix", *UNATTENDED)
|
|
312
|
+
assert rig.code == 1 and rig.pushes() == [] and not rig.prs(), rig.out
|
|
313
|
+
assert rig.led("Ruling: final-review finding stays open and nothing is pushed (fix): STILL-OPEN")
|
|
314
|
+
assert rig.led("End of run: BLOCKED at the operator keeps 1 finding of the final review open (fix): nothing is pushed.")
|
|
315
|
+
assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_a_commit_after_the_second_review_is_never_reviewed_or_pushed(tmp_path):
|
|
319
|
+
rig, steps = parked(tmp_path, finding("STILL-OPEN"))
|
|
320
|
+
rig.run(steps, "--answer", "final-finding-1=accept")
|
|
321
|
+
assert rig.code == 0 and all(stop_lines(rig.out))
|
|
322
|
+
stray(rig, "fix(01): by hand")
|
|
323
|
+
rig.run(steps, "--push")
|
|
324
|
+
assert rig.code == 1 and rig.pushes() == [] and len(rig.final_calls("final-reviewer")) == 2
|
|
325
|
+
assert rig.led("End of run: BLOCKED at the two review rounds are done and the tip", "is not the reviewed tip")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------- the rebase
|
|
329
|
+
|
|
330
|
+
def moved(tmp_path, base, *conflict, **kw):
|
|
331
|
+
rig = Inside(tmp_path, THREE, **kw)
|
|
332
|
+
rig.move_base(base)
|
|
333
|
+
return rig.run(plan(**{"final/conflict": list(conflict)}), *UNATTENDED)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_a_base_that_moved_with_no_conflict_is_rebased_onto_with_no_agent_and_one_gate(tmp_path):
|
|
337
|
+
rig = moved(tmp_path, {"docs.md": "base docs\n"})
|
|
338
|
+
assert rig.code == 0 and not rig.final_calls("conflict"), rig.out
|
|
339
|
+
base, rows = ref(rig.origin, "refs/heads/main"), rig.commits(ref(rig.origin, "refs/heads/main"))
|
|
340
|
+
order = rig.order()
|
|
341
|
+
assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in order] and order[2] == "02"
|
|
342
|
+
assert ref(rig.repo, "feat/csv~3") == base and git(rig.repo, "show", "feat/csv:docs.md") == "base docs\n"
|
|
343
|
+
assert [p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log")] == [f"gate-{rig.branch()[:7]}.log"]
|
|
344
|
+
assert rig.final()["commits"] == {nn: row["sha"] for nn, row in zip(order, rows)}
|
|
345
|
+
assert rig.final()["onto"] == base and f"Range: {base}..{rig.branch()}\n" in rig.final_calls("final-reviewer")[0]["prompt"]
|
|
346
|
+
assert rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and stage(rig) == {"stage": "implement", "status": "done"}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_a_resolved_rebase_keeps_the_ticket_messages_and_gates_the_rebased_tip_and_the_tidied_tip(tmp_path):
|
|
350
|
+
cli = {"demo/cli.py": "def main():\n return 'base'\n"}
|
|
351
|
+
resolved = {**RESOLVE, "files": {"demo/cli.py": "def main():\n return 'both'\n"}, "git_end": []}
|
|
352
|
+
rig = moved(tmp_path, cli, resolved) # the agent commits as "resolve conflict" and the driver goes on with the rebase
|
|
353
|
+
assert rig.code == 0, rig.out + "\n".join(rig.ledger())
|
|
354
|
+
(call,) = rig.final_calls("conflict")
|
|
355
|
+
assert "Files in conflict: demo/cli.py\n" in call["prompt"] and "Base branch: main, at commit " in call["prompt"]
|
|
356
|
+
assert call["branch"] == "HEAD" and call["prompt"].rstrip().endswith("one ref store.")
|
|
357
|
+
rows = rig.commits(ref(rig.origin, "refs/heads/main"))
|
|
358
|
+
assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in rig.order()]
|
|
359
|
+
assert git(rig.repo, "show", "feat/csv:demo/cli.py") == "def main():\n return 'both'\n"
|
|
360
|
+
(line,) = rig.led("Rebase: the conflict agent resolved the conflict with main in demo/cli.py at ")
|
|
361
|
+
rebased = line.rstrip(".").split()[-1]
|
|
362
|
+
records = sorted(p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log"))
|
|
363
|
+
assert records == sorted([f"gate-{rebased}.log", f"gate-{rig.branch()[:7]}.log"]) and rebased != rig.branch()[:7]
|
|
364
|
+
assert ref(rig.repo, f"{rebased}^{{tree}}") == ref(rig.repo, "feat/csv^{tree}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_a_second_conflict_leaves_the_branch_at_its_recovery_point_with_all_its_commits(tmp_path):
|
|
368
|
+
step = {**RESOLVE, "files": {"demo/export.py": "resolved = 1\n"}, "report": {**RESOLVE["report"], "files": ["demo/export.py"]}}
|
|
369
|
+
rig = moved(tmp_path, {"demo/export.py": "def export_csv():\n return 'base'\n"}, step)
|
|
370
|
+
assert rig.code == 1 and len(rig.final_calls("conflict")) == 1 and not rig.final_calls("final-reviewer"), rig.out
|
|
371
|
+
recovery = rig.recovery()
|
|
372
|
+
assert rig.branch() == recovery and len(rig.commits(rig.start, recovery)) == 6, "two commits per ticket, as before"
|
|
373
|
+
assert rig.led("End of run: BLOCKED at the rewrite: the rebase onto main conflicts a second time, in demo/export.py; "
|
|
374
|
+
f"feat/csv is unrewritten at its recovery point {recovery[:7]}.")
|
|
375
|
+
assert git(rig.repo, "symbolic-ref", "--short", "HEAD").strip() == "feat/csv" and git(rig.repo, "status", "--short") == ""
|
|
376
|
+
assert rig.pushes() == [] and stage(rig)["blocked"] == ["final"] and not rig.final().get("tidied")
|
|
377
|
+
assert {e["phase"] for e in rig.state()["tickets"].values()} == {"merged"}
|
|
378
|
+
|
|
379
|
+
rig.run(plan(), *UNATTENDED) # the rerun starts the rewrite again from the same recovery point
|
|
380
|
+
assert rig.code == 1 and rig.recovery() == recovery == rig.branch()
|
|
381
|
+
assert len(rig.final_calls("conflict")) == 3, "the conflict agent of the rerun, which has no script, and its one rerun"
|
|
382
|
+
assert not rig.led("returned to implement")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_a_conflict_agent_that_gives_up_leaves_no_rebase_open(tmp_path):
|
|
386
|
+
stuck = {"report": {"status": "BLOCKED", "commits": [], "files": [], "concerns": ["both sides are needed"]}}
|
|
387
|
+
rig = moved(tmp_path, {"demo/cli.py": "base\n"}, stuck)
|
|
388
|
+
assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
|
|
389
|
+
assert rig.led("End of run: BLOCKED at the rewrite: the conflict agent: the conflict reported BLOCKED: both sides are needed;")
|
|
390
|
+
assert git(rig.repo, "status", "--short") == "" and git(rig.repo, "symbolic-ref", "--short", "HEAD") == "feat/csv\n"
|
|
391
|
+
assert not (rig.repo / ".git/rebase-merge").exists()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_a_red_gate_after_the_rebase_leaves_the_branch_unrewritten(tmp_path):
|
|
395
|
+
rig = Inside(tmp_path, ONE, gate={"red": ["HEAD"]}) # red on a detached commit: the rebased tip
|
|
396
|
+
rig.move_base({"demo/export.py": "def export_csv():\n return 'base'\n"})
|
|
397
|
+
rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/export.py": "x = 1\n"}}]}))
|
|
398
|
+
assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
|
|
399
|
+
assert rig.led("End of run: BLOCKED at the rewrite: the gate after the rebase: the gate is red on ")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_a_base_that_holds_a_ticket_commit_already_leaves_the_branch_unrewritten(tmp_path):
|
|
403
|
+
rig = Inside(tmp_path, ONE)
|
|
404
|
+
rig.move_base({**tw("01")["files"], "demo/export.py": fn("01")}) # the same change as the commit of ticket 01
|
|
405
|
+
rig.run(plan())
|
|
406
|
+
assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
|
|
407
|
+
assert rig.led("End of run: BLOCKED at the rewrite: the rebase onto main left 0 commits for 1;")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@pytest.mark.parametrize("patch, error", [
|
|
411
|
+
(lambda rig: ("ancestor", lambda commit, of, real=driver_merge.ancestor: commit != rig.start and real(commit, of)),
|
|
412
|
+
"feat/csv no longer holds {start}, the commit the run started from"),
|
|
413
|
+
(lambda rig: ("same_tree", lambda one, other: False), "the tidied history does not have the tree of {old}"),
|
|
414
|
+
])
|
|
415
|
+
def test_a_history_the_tidy_cannot_trust_is_not_rewritten(tmp_path, monkeypatch, patch, error):
|
|
416
|
+
rig = Inside(tmp_path, ONE)
|
|
417
|
+
name, fake = patch(rig)
|
|
418
|
+
monkeypatch.setattr(driver_tidy if name == "same_tree" else driver_merge, name, fake)
|
|
419
|
+
rig.run(plan())
|
|
420
|
+
assert rig.code == 1 and rig.branch() == rig.recovery() and not rig.final().get("tidied"), rig.out
|
|
421
|
+
assert rig.led("End of run: BLOCKED at the rewrite: " + error.format(start=rig.start[:7], old=rig.recovery()[:7]))
|
|
422
|
+
assert len(rig.commits(rig.start)) == 2, "the two commits of the ticket, as they were"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@pytest.mark.parametrize("change, error", [
|
|
426
|
+
(lambda rig: git(rig.repo, "remote", "set-url", "origin", str(rig.tmp / "nowhere.git")), "origin names no default branch: "),
|
|
427
|
+
(lambda rig: git(rig.origin, "symbolic-ref", "HEAD", "refs/heads/feat/csv"),
|
|
428
|
+
"feat/csv is the default branch of origin: the end of run needs a PR branch."),
|
|
429
|
+
])
|
|
430
|
+
def test_an_origin_with_no_base_for_the_pr_branch_stops_the_end_of_run_before_the_recovery_point(tmp_path, change, error):
|
|
431
|
+
rig = Inside(tmp_path, ONE)
|
|
432
|
+
git(rig.repo, "push", "-q", "origin", "feat/csv")
|
|
433
|
+
change(rig)
|
|
434
|
+
rig.run(plan())
|
|
435
|
+
assert rig.code == 1 and not rig.recovery() and len(rig.commits(rig.start)) == 2, rig.out
|
|
436
|
+
assert rig.led("End of run: BLOCKED at " + error)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------- the push
|
|
440
|
+
|
|
441
|
+
def test_a_push_or_a_pr_that_fails_is_tried_again_and_nothing_is_sent_twice(tmp_path):
|
|
442
|
+
rig = Inside(tmp_path, ONE)
|
|
443
|
+
gh, hook = tmp_path / "bin/gh", rig.origin / "hooks/pre-receive"
|
|
444
|
+
script, accept = gh.read_text(), hook.read_text()
|
|
445
|
+
gh.unlink()
|
|
446
|
+
rig.run(plan(), *UNATTENDED)
|
|
447
|
+
assert rig.code == 1 and rig.pushes() == [], rig.out
|
|
448
|
+
assert rig.led("End of run: BLOCKED at gh is not on PATH: nothing is pushed.")
|
|
449
|
+
assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
|
|
450
|
+
|
|
451
|
+
gh.write_text("#!/bin/sh\necho no token >&2\nexit 4\n")
|
|
452
|
+
gh.chmod(0o755)
|
|
453
|
+
hook.write_text("#!/bin/sh\necho REFUSED >&2\nexit 1\n")
|
|
454
|
+
rig.run(plan(), *UNATTENDED)
|
|
455
|
+
assert rig.code == 1 and not ref(rig.origin, "refs/heads/feat/csv") and not rig.final().get("pushed")
|
|
456
|
+
assert rig.led("End of run: BLOCKED at git push origin feat/csv failed: ", "REFUSED")
|
|
457
|
+
|
|
458
|
+
hook.write_text(accept)
|
|
459
|
+
rig.run(plan(), *UNATTENDED)
|
|
460
|
+
assert rig.code == 1 and rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and not rig.final().get("pr")
|
|
461
|
+
assert rig.led("End of run: BLOCKED at gh pr create failed: no token.")
|
|
462
|
+
assert f"feat/csv is pushed at {rig.branch()[:7]}; its PR is not open." in rig.out
|
|
463
|
+
|
|
464
|
+
gh.write_text(script)
|
|
465
|
+
calls = len(rig.calls())
|
|
466
|
+
rig.run(plan(), *UNATTENDED)
|
|
467
|
+
assert rig.code == 0 and len(rig.pushes()) == 1 and len(rig.prs()) == 1 and len(rig.calls()) == calls, rig.out
|
|
468
|
+
assert stage(rig) == {"stage": "implement", "status": "done"} and rig.final()["pr"] == "https://github.example/pull/1"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_a_reviewed_tip_with_no_green_gate_record_is_not_pushed(tmp_path):
|
|
472
|
+
rig = Inside(tmp_path, ONE)
|
|
473
|
+
rig.run(plan())
|
|
474
|
+
state = rig.state()
|
|
475
|
+
state["run"]["final"]["green_gates"] = []
|
|
476
|
+
(rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
477
|
+
rig.run(plan(), "--push")
|
|
478
|
+
assert rig.code == 1 and rig.pushes() == [] and not rig.prs(), rig.out
|
|
479
|
+
assert rig.led(f"End of run: BLOCKED at the reviewed tip {rig.branch()[:7]} is not the tip of feat/csv with a green "
|
|
480
|
+
"gate record: nothing is pushed.")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_the_rerun_command_drops_the_answers_and_the_pr_title_falls_back_to_the_branch():
|
|
484
|
+
command = driver_final.again(["--gate", "make check", "--answer", "final-finding-1=accept", "--answer=x=y", "--spec", "s.md"])
|
|
485
|
+
assert command == f"sh {driver_final.driver_agents.SKILL / 'run.sh'} --gate 'make check' --spec s.md --push"
|
|
486
|
+
assert driver_final._name(SimpleNamespace(args=SimpleNamespace(spec="nowhere.md"), pr="feat/x")) == "feat/x"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------- preconditions and the skip
|
|
490
|
+
|
|
491
|
+
@pytest.mark.parametrize("remote", [True, False], ids=["origin", "no-origin"])
|
|
492
|
+
def test_without_the_review_diff_skill_every_repository_stops_before_any_agent(tmp_path, monkeypatch, remote):
|
|
493
|
+
monkeypatch.setattr(driver_final, "REVIEW_DIFF", tmp_path / "review-diff/SKILL.md")
|
|
494
|
+
rig = Inside(tmp_path, ONE, remote=remote).run(plan())
|
|
495
|
+
assert rig.code == 3 and not rig.calls(), rig.out
|
|
496
|
+
assert rig.out == (f"{tmp_path}/review-diff/SKILL.md is missing: the final review runs through it; install the "
|
|
497
|
+
"review-diff skill next to implement-spec\n")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def test_a_push_without_the_pr_skill_stops_before_any_agent_and_a_local_run_does_not_need_it(tmp_path, monkeypatch):
|
|
501
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
502
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "empty-claude-home"))
|
|
503
|
+
rig = Inside(tmp_path, ONE).run(plan(), "--push")
|
|
504
|
+
assert rig.code == 3 and not rig.calls(), rig.out
|
|
505
|
+
assert rig.out.startswith("the pr skill is missing: the PR body follows it; install it with ")
|
|
506
|
+
|
|
507
|
+
rig.run(plan())
|
|
508
|
+
assert rig.code == 0 and not rig.final_calls("pr-writer"), rig.out
|
|
509
|
+
|
|
510
|
+
(tmp_path / "home/.agents/skills/pr").mkdir(parents=True)
|
|
511
|
+
(tmp_path / "home/.agents/skills/pr/SKILL.md").write_text("---\nname: pr\n---\n")
|
|
512
|
+
rig.run(plan(), "--push")
|
|
513
|
+
assert rig.code == 0 and len(rig.prs()) == 1, rig.out
|
|
514
|
+
(writer,) = rig.final_calls("pr-writer")
|
|
515
|
+
assert f"PR method: {tmp_path}/home/.agents/skills/pr/SKILL.md\n" in writer["prompt"]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def test_without_an_origin_the_run_ends_reviewed_and_a_push_is_refused_at_the_start_and_after_the_tidy(tmp_path):
|
|
519
|
+
rig = Inside(tmp_path, ONE, remote=False).run(plan())
|
|
520
|
+
assert rig.code == 0, rig.out
|
|
521
|
+
tip, final = rig.branch(), rig.final()
|
|
522
|
+
assert (final["base"], final["onto"], final["reviewed"]) == ("", rig.state()["run"]["start"], tip)
|
|
523
|
+
assert rig.led("no origin") == ["End of run: the repository has no origin remote, so the rebase and the push are "
|
|
524
|
+
"skipped; the tidy, the gate and the final review run."]
|
|
525
|
+
assert (f"1 of 1 tickets are merged into feat/csv.\nThe final review covers feat/csv up to {tip[:7]}. Nothing is "
|
|
526
|
+
"pushed.\n\n| Measure") in rig.out
|
|
527
|
+
assert rig.out.endswith(f"Reviewed tip: {tip}\nNothing is pushed. The repository has no origin remote.\n")
|
|
528
|
+
# A run that is to push needs the remote: it stops before the lock of a new run and before any agent.
|
|
529
|
+
calls = len(rig.calls())
|
|
530
|
+
for flags in (UNATTENDED, ("--push",)):
|
|
531
|
+
rig.run(plan(), *flags)
|
|
532
|
+
assert rig.code == 3 and len(rig.calls()) == calls and not (rig.repo / ".agent/stage.json").exists()
|
|
533
|
+
assert rig.out == ("the repository has no origin remote: --unattended and --push push the PR branch to origin; "
|
|
534
|
+
"add the remote, or run without them to stop before the push\n")
|
|
535
|
+
# An origin that comes after the tidy does not make the branch a PR branch: it is on no base of that origin.
|
|
536
|
+
git(rig.repo, "remote", "add", "origin", str(tmp_path / "late.git"))
|
|
537
|
+
rig.run(plan(), "--push")
|
|
538
|
+
assert rig.code == 1 and not rig.pushes() and not rig.prs() and rig.branch() == tip
|
|
539
|
+
assert rig.led("BLOCKED") == [
|
|
540
|
+
"End of run: BLOCKED at the end of run tidied feat/csv when the repository had no origin remote, so it is on "
|
|
541
|
+
"no base branch of origin: nothing is pushed."]
|
|
542
|
+
fresh = {"pr_branch": "feat/x", "gates": 0, "stops": 0, "agents": [], "final": driver_state._final()}
|
|
543
|
+
assert driver_summary.render({"run": fresh, "tickets": {}}).startswith("0 of 0 tickets are merged into feat/x.\n\n| Measure")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@pytest.mark.parametrize("name", ["final-reviewer", "final-fixer", "rebase-conflict"])
|
|
547
|
+
def test_the_templates_of_the_end_of_run_hold_a_valid_schema_and_end_with_the_rule(name):
|
|
548
|
+
jsonschema.Draft202012Validator.check_schema(driver_report.schema(name))
|
|
549
|
+
text = (driver_report.PROMPTS / f"{name}.md").read_text()
|
|
550
|
+
assert text.rstrip().endswith("one ref store.") and text.rfind("{report}") < text.rfind("DONE_WITH_CONCERNS")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ---------------------------------------------------------------- fix round 1 of ticket 06
|
|
554
|
+
|
|
555
|
+
@pytest.mark.parametrize("ref_name", ["feat/csv", "other"])
|
|
556
|
+
def test_a_history_that_origin_already_has_is_not_rebased_and_not_tidied(tmp_path, ref_name):
|
|
557
|
+
"""The PR branch has a commit of its own before the run, and origin has it, as the PR branch or under
|
|
558
|
+
another ref. Then the base moves."""
|
|
559
|
+
rig = Inside(tmp_path, ONE)
|
|
560
|
+
stray(rig, "published before the run")
|
|
561
|
+
published = rig.branch()
|
|
562
|
+
git(rig.repo, "push", "-q", "origin", f"feat/csv:refs/heads/{ref_name}")
|
|
563
|
+
rig.move_base({"docs.md": "base docs\n"})
|
|
564
|
+
rig.run(plan(), *UNATTENDED)
|
|
565
|
+
assert rig.code == 0, rig.out + "\n".join(rig.ledger())
|
|
566
|
+
assert [r["subject"] for r in rig.commits(published)] == ["test: 01 red", "feat: 01 rows"], "no tidy"
|
|
567
|
+
assert ref(rig.repo, "feat/csv~2") == published and not rig.recovery(), "no rebase, so no recovery point"
|
|
568
|
+
assert len(rig.led("End of run: origin has commits of feat/csv, so its history is kept as it is: no tidy and no rebase")) == 1
|
|
569
|
+
assert len(rig.final_calls("final-reviewer")) == 1 and rig.led("End of run: the gate is green on " + rig.branch()[:7])
|
|
570
|
+
assert rig.pushes()[-1] == f"refs/heads/feat/csv {rig.branch()}" and ref(rig.origin, "refs/heads/feat/csv") == rig.branch()
|
|
571
|
+
assert f"- 01: {rig.branch()[:7]} Export rows" in rig.prs()[0]["body"] and stage(rig)["status"] == "done"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def weak(nn="01"):
|
|
575
|
+
return {f"tests/test_{nn}.py": "def test_x():\n assert True\n"}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def test_a_fix_batch_that_changes_a_locked_test_gives_a_critical_finding_and_is_not_pushed(tmp_path):
|
|
579
|
+
rig = Inside(tmp_path, ONE)
|
|
580
|
+
batch = fixer(fn("01"), "weaken the test", ticket_of="01")
|
|
581
|
+
batch["commits"][0]["files"].update(weak())
|
|
582
|
+
rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE")), final()], "final/fixer": [batch]}), *UNATTENDED)
|
|
583
|
+
assert rig.code == 2 and rig.pushes() == [] and not rig.prs(), rig.out
|
|
584
|
+
assert rig.led("Final review round 2: critical finding of kind code at tests/test_01.py:1: locked file changed: tests/test_01.py.")
|
|
585
|
+
(q,) = decisions(rig)["questions"]
|
|
586
|
+
assert q["title"].endswith("locked file changed: tests/test_01.py (tests/test_01.py:1)")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_a_rebase_resolution_that_changes_a_locked_test_leaves_the_branch_at_its_recovery_point(tmp_path):
|
|
590
|
+
rig = Inside(tmp_path, ONE)
|
|
591
|
+
rig.move_base({"demo/export.py": "def export_csv():\n return 'base'\n"})
|
|
592
|
+
rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/export.py": "x = 1\n", **weak()}}]}), *UNATTENDED)
|
|
593
|
+
assert rig.code == 1 and rig.pushes() == [] and rig.branch() == rig.recovery(), rig.out
|
|
594
|
+
assert rig.led("End of run: BLOCKED at the rewrite: the conflict agent: locked file changed: tests/test_01.py;")
|
|
595
|
+
assert git(rig.repo, "status", "--short") == "" and not rig.final_calls("final-reviewer")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_a_rebase_with_no_commit_to_replay_puts_each_ticket_on_the_fetched_base(tmp_path, monkeypatch):
|
|
599
|
+
rig = Inside(tmp_path, ONE)
|
|
600
|
+
rig.move_base({"docs.md": "base docs\n"})
|
|
601
|
+
git(rig.repo, "fetch", "-q", "origin")
|
|
602
|
+
onto = ref(rig.repo, "refs/remotes/origin/main")
|
|
603
|
+
monkeypatch.chdir(rig.repo)
|
|
604
|
+
assert driver_final._rebased(None, {"01": rig.start, "02": rig.start}, "main", onto) == {"01": onto, "02": onto}
|