@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,534 @@
|
|
|
1
|
+
"""The end of a run, when every ticket is merged and every slot is idle: tidy the PR branch into one commit per
|
|
2
|
+
ticket, rebase it onto the fetched base tip, gate it, run the final review, let one fix batch land as `fix(NN)`
|
|
3
|
+
commits with a scoped review, then stop before the push, or push and open the PR.
|
|
4
|
+
|
|
5
|
+
`run.final` of state.json is the resume contract (`driver_state._final` makes it with every key):
|
|
6
|
+
skipped why the end-of-run work did not start
|
|
7
|
+
recovery the PR tip before the rewrite; the ref RECOVERY holds it. None when origin had the history already
|
|
8
|
+
tidied the tip the rewrite made, or the tip as the tickets left it when the history is kept
|
|
9
|
+
base, onto the default branch of origin and its tip under the history; with no origin: "" and the start of the run
|
|
10
|
+
commits {ticket: its commit}
|
|
11
|
+
reviewed the last tip a review covered
|
|
12
|
+
round the reviews done
|
|
13
|
+
open the findings the fix batch is due for
|
|
14
|
+
batch the tip of the fix batch that the driver validated and named
|
|
15
|
+
green_gates, pushed, pr
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import driver_agents
|
|
28
|
+
import driver_decisions
|
|
29
|
+
import driver_merge
|
|
30
|
+
import driver_review
|
|
31
|
+
import driver_tidy
|
|
32
|
+
from driver_run import FINAL, OUT, Blocked, Run, during, ledger
|
|
33
|
+
from driver_slots import git, head
|
|
34
|
+
from driver_state import State
|
|
35
|
+
from driver_tickets import Ticket
|
|
36
|
+
|
|
37
|
+
RECOVERY = "refs/implement/recovery"
|
|
38
|
+
KIND = "final-finding"
|
|
39
|
+
ROUNDS = 2 # the full review and the scoped one after the fix batch
|
|
40
|
+
# The final review runs through the review-diff skill, which is installed next to this one.
|
|
41
|
+
REVIEW_DIFF = driver_agents.SKILL.parent / "review-diff" / "SKILL.md"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pr_skill() -> Path | None:
|
|
45
|
+
"""The SKILL.md of the pr skill, which the PR body follows: next to this skill, else in the skill folder of the
|
|
46
|
+
Claude home or of the shared home of Codex and OpenCode."""
|
|
47
|
+
claude = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
48
|
+
homes = (driver_agents.SKILL.parent, claude / "skills", Path.home() / ".agents" / "skills")
|
|
49
|
+
return next((home / "pr" / "SKILL.md" for home in homes if (home / "pr" / "SKILL.md").is_file()), None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _has_origin() -> bool:
|
|
53
|
+
return git(".", "remote", "get-url", "origin").returncode == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def precondition_errors(push: bool) -> list[str]:
|
|
57
|
+
"""[] or the errors that stop the run before any agent."""
|
|
58
|
+
errors = []
|
|
59
|
+
if not REVIEW_DIFF.exists():
|
|
60
|
+
errors.append(f"{REVIEW_DIFF} is missing: the final review runs through it; install the review-diff skill "
|
|
61
|
+
"next to implement-spec")
|
|
62
|
+
if push and not pr_skill():
|
|
63
|
+
errors.append("the pr skill is missing: the PR body follows it; install it with "
|
|
64
|
+
"`npx skills add mattpocock/skills --skill pr`, or run without --push and --unattended")
|
|
65
|
+
if push and not _has_origin():
|
|
66
|
+
errors.append("the repository has no origin remote: --unattended and --push push the PR branch to origin; "
|
|
67
|
+
"add the remote, or run without them to stop before the push")
|
|
68
|
+
return errors
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def finish_interrupted_move(state: State, pr: str) -> None:
|
|
72
|
+
"""A run that stopped between the record of the tidied tip and the move of the PR branch: move it now."""
|
|
73
|
+
final = state.read()["run"]["final"]
|
|
74
|
+
if final["tidied"] and git(".", "rev-parse", f"refs/heads/{pr}").stdout.strip() == final["recovery"]:
|
|
75
|
+
git(".", "checkout", "-q", "--detach")
|
|
76
|
+
driver_merge.land(pr, final["tidied"], final["recovery"])
|
|
77
|
+
git(".", "checkout", "-q", pr)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _prompt_values(run: Run) -> dict[str, str]:
|
|
81
|
+
"""The pointers every prompt of the end of run gets. Its agents work in slot 1, the PR worktree."""
|
|
82
|
+
return {"spec": run.args.spec, "tickets": run.args.tickets, "pr_branch": run.pr, "branch": run.pr}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _title(finding: dict) -> str:
|
|
86
|
+
return f"{finding['title']} ({finding['file']}:{finding['line']})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _locked_files(run: Run) -> list[str]:
|
|
90
|
+
return sorted({path for entry in run.state.read()["tickets"].values() for path in entry["locked"]})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _last(commits: dict[str, str]) -> str:
|
|
94
|
+
return list(commits.values())[-1]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- the rewrite: tidy, rebase, move the branch
|
|
98
|
+
|
|
99
|
+
def _fetch_base() -> tuple[str, str]:
|
|
100
|
+
"""Fetch every ref of origin. Returns (its default branch, the tip of that branch)."""
|
|
101
|
+
shown = git(".", "ls-remote", "--symref", "origin", "HEAD")
|
|
102
|
+
name = re.search(r"^ref: refs/heads/(\S+)\s+HEAD$", shown.stdout, re.M)
|
|
103
|
+
if not name:
|
|
104
|
+
raise Blocked(f"origin names no default branch: {shown.stderr.strip() or 'git ls-remote shows no HEAD'}")
|
|
105
|
+
fetched = git(".", "fetch", "-q", "origin")
|
|
106
|
+
onto = git(".", "rev-parse", "-q", "--verify", f"refs/remotes/origin/{name[1]}^{{commit}}").stdout.strip()
|
|
107
|
+
if fetched.returncode or not onto:
|
|
108
|
+
raise Blocked(f"git fetch origin did not give refs/remotes/origin/{name[1]}: {fetched.stderr.strip()}")
|
|
109
|
+
return name[1], onto
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _base(run: Run) -> tuple[str, str]:
|
|
113
|
+
"""(the default branch of origin, its tip): what the tidied history sits on. With no origin there is no base:
|
|
114
|
+
("", the commit the run started from)."""
|
|
115
|
+
if not _has_origin():
|
|
116
|
+
ledger("End of run: the repository has no origin remote, so the rebase and the push are skipped; the "
|
|
117
|
+
"tidy, the gate and the final review run.")
|
|
118
|
+
return "", run.state.read()["run"]["start"]
|
|
119
|
+
base, onto = _fetch_base()
|
|
120
|
+
if base == run.pr:
|
|
121
|
+
raise Blocked(f"{run.pr} is the default branch of origin: the end of run needs a PR branch")
|
|
122
|
+
return base, onto
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _origin_has_commits(pr: str, tip: str, onto: str) -> bool:
|
|
126
|
+
"""origin has the PR branch, or a ref of origin holds a commit of `tip` that the base tip `onto` does not."""
|
|
127
|
+
def commits_not_in(*others: str) -> str:
|
|
128
|
+
return git(".", "rev-list", "--count", tip, "--not", *others).stdout.strip()
|
|
129
|
+
|
|
130
|
+
return (commits_not_in(onto) != commits_not_in("--remotes=origin")
|
|
131
|
+
or bool(git(".", "ls-remote", "origin", f"refs/heads/{pr}").stdout.strip()))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _keep_published_history(run: Run, tip: str, base: str, onto: str) -> None:
|
|
135
|
+
"""Record the history as it is: a history that origin has is never rewritten."""
|
|
136
|
+
commits = dict(run.state.read()["run"]["landed"])
|
|
137
|
+
run.state.update(lambda state: state["run"]["final"].update(tidied=tip, onto=onto, base=base, commits=commits))
|
|
138
|
+
ledger(f"End of run: origin has commits of {run.pr}, so its history is kept as it is: no tidy and no rebase, "
|
|
139
|
+
f"because a published history is not rewritten. The gate and the final review run on {tip[:7]}.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _rebase_is_open() -> bool:
|
|
143
|
+
return any(Path(git(".", "rev-parse", "--git-path", name).stdout.strip()).exists()
|
|
144
|
+
for name in ("rebase-merge", "rebase-apply"))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _files_in_conflict() -> list[str]:
|
|
148
|
+
return git(".", "diff", "--name-only", "--diff-filter=U").stdout.split()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_rebase_conflict(run: Run, env: dict, base: str, onto: str) -> None:
|
|
152
|
+
"""Run the conflict agent, once, on the rebase that is open in slot 1, then gate the rebased tip. No rebase is
|
|
153
|
+
open afterwards, also when this raises."""
|
|
154
|
+
files = _files_in_conflict()
|
|
155
|
+
values = {**_prompt_values(run), "branch": "HEAD", "start_sha": onto, "base": base, "files": " ".join(files)}
|
|
156
|
+
error = ""
|
|
157
|
+
try:
|
|
158
|
+
run.agent("conflict", FINAL, ".", values, template="rebase-conflict", detached=True)
|
|
159
|
+
except Blocked as blocked:
|
|
160
|
+
error = str(blocked)
|
|
161
|
+
if not error and _rebase_is_open(): # the agent resolved the files and did not go on
|
|
162
|
+
subprocess.run(["git", "-c", "core.editor=true", "rebase", "--continue"], capture_output=True, env=env)
|
|
163
|
+
if _rebase_is_open():
|
|
164
|
+
again = _files_in_conflict()
|
|
165
|
+
git(".", "rebase", "--abort")
|
|
166
|
+
raise Blocked(f"the conflict agent: {error}" if error else
|
|
167
|
+
f"the rebase onto {base} conflicts a second time, in {', '.join(again)}")
|
|
168
|
+
# The resolution may not change a locked test: each one is as the recovery point has it.
|
|
169
|
+
changed = driver_review.locked_changes(".", git(".", "rev-parse", RECOVERY).stdout.strip(), _locked_files(run))
|
|
170
|
+
if error := error or "; ".join(finding["title"] for finding in changed):
|
|
171
|
+
raise Blocked(f"the conflict agent: {error}")
|
|
172
|
+
ledger(f"Rebase: the conflict agent resolved the conflict with {base} in {', '.join(files)} at {head('.')[:7]}.")
|
|
173
|
+
with during("the gate after the rebase"):
|
|
174
|
+
run.gate(FINAL, ".", head("."))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _rebase(run: Run, tip: str, base: str, onto: str) -> None:
|
|
178
|
+
"""`git rebase` of `tip` onto `onto` in slot 1, on a detached HEAD: no ref moves. HEAD is the rebased tip."""
|
|
179
|
+
env = {**os.environ, **driver_merge.identity(tip)}
|
|
180
|
+
git(".", "checkout", "-q", "--detach", tip)
|
|
181
|
+
done = subprocess.run(["git", "-c", "core.editor=true", "rebase", onto], capture_output=True, text=True, env=env)
|
|
182
|
+
if _rebase_is_open():
|
|
183
|
+
_resolve_rebase_conflict(run, env, base, onto)
|
|
184
|
+
elif done.returncode:
|
|
185
|
+
raise Blocked(f"git rebase: {done.stderr.strip()}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _rebased(run: Run, commits: dict[str, str], base: str, onto: str) -> dict[str, str]:
|
|
189
|
+
"""Rebase the tidied history onto `onto`, the tip of `base`. Returns {ticket: its commit on the new base}."""
|
|
190
|
+
tip = _last(commits)
|
|
191
|
+
before = git(".", "rev-list", "--reverse", f"{onto}..{tip}").stdout.split()
|
|
192
|
+
if not before: # no ticket left a change, and the base tip holds the commit under them
|
|
193
|
+
return dict.fromkeys(commits, onto)
|
|
194
|
+
_rebase(run, tip, base, onto)
|
|
195
|
+
after = git(".", "rev-list", "--reverse", f"{onto}..HEAD").stdout.split()
|
|
196
|
+
if len(after) != len(before):
|
|
197
|
+
raise Blocked(f"the rebase onto {base} left {len(after)} commits for {len(before)}")
|
|
198
|
+
# A conflict resolution may have changed a message: each rebased commit gets its tidied message again.
|
|
199
|
+
made = driver_tidy.rewrite(onto, [(new, driver_tidy.text(old)) for new, old in zip(after, before)])
|
|
200
|
+
if not driver_tidy.same_tree(made[-1], head(".")):
|
|
201
|
+
raise Blocked(f"the renamed commits do not have the tree of the rebased tip {head('.')[:7]}")
|
|
202
|
+
moved = dict(zip(before, made))
|
|
203
|
+
return {number: moved.get(commit, commit) for number, commit in commits.items()}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _new_history(run: Run, tickets: list[Ticket], old: str, base: str, onto: str) -> dict[str, str]:
|
|
207
|
+
"""Make the new history of the PR branch, with no ref moved. Returns {ticket: its commit}."""
|
|
208
|
+
record = run.state.read()["run"]
|
|
209
|
+
if not driver_merge.ancestor(record["start"], old):
|
|
210
|
+
raise Blocked(f"{run.pr} no longer holds {record['start'][:7]}, the commit the run started from")
|
|
211
|
+
# The tidy comes before the rebase: its tree check is exact, and the rebase moves one commit per ticket.
|
|
212
|
+
commits = driver_tidy.tidy(record["start"], record["landed"], {ticket.number: ticket for ticket in tickets})
|
|
213
|
+
if not driver_tidy.same_tree(_last(commits), old):
|
|
214
|
+
raise Blocked(f"the tidied history does not have the tree of {old[:7]}")
|
|
215
|
+
if driver_merge.ancestor(onto, _last(commits)):
|
|
216
|
+
return commits
|
|
217
|
+
return _rebased(run, commits, base, onto)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _move_branch(run: Run, commits: dict[str, str], old: str, base: str, onto: str) -> None:
|
|
221
|
+
"""Record the new history, then move the PR branch to it."""
|
|
222
|
+
new = _last(commits)
|
|
223
|
+
old_tips = {number: entry["tip"] for number, entry in run.state.read()["tickets"].items()}
|
|
224
|
+
|
|
225
|
+
def record(state: dict, tips: dict, **fields) -> None:
|
|
226
|
+
state["run"]["final"].update(fields)
|
|
227
|
+
for number, tip in tips.items():
|
|
228
|
+
state["tickets"][number]["tip"] = tip
|
|
229
|
+
|
|
230
|
+
# The record comes first and names each ticket's new commit as its tip: a resume confirms a merged ticket by
|
|
231
|
+
# that tip, and `finish_interrupted_move` moves the branch when the run stopped between the two writes.
|
|
232
|
+
run.state.update(lambda state: record(state, commits, recovery=old, tidied=new, onto=onto, base=base,
|
|
233
|
+
commits=commits))
|
|
234
|
+
if error := driver_merge.land(run.pr, new, old):
|
|
235
|
+
run.state.update(lambda state: record(state, old_tips, tidied=None))
|
|
236
|
+
raise Blocked(f"cannot move {run.pr} to the tidied tip {new[:7]}: {error}")
|
|
237
|
+
each = ", ".join(f"{number} is {commit[:7]}" for number, commit in commits.items())
|
|
238
|
+
ledger(f"Tidy: {run.pr} moved from {old[:7]} to {new[:7]} on {base or 'the start of the run'} at {onto[:7]}, "
|
|
239
|
+
f"with one commit per ticket in merge order: {each}.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
|
|
243
|
+
"""The one rewrite of the PR branch: recovery point, tidy, rebase, then the move. When it raises, the branch
|
|
244
|
+
is at its recovery point."""
|
|
245
|
+
final = run.state.read()["run"]["final"]
|
|
246
|
+
if final["tidied"]:
|
|
247
|
+
if not driver_merge.ancestor(final["tidied"], run.tip()):
|
|
248
|
+
raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {RECOVERY} holds the "
|
|
249
|
+
"history before the tidy")
|
|
250
|
+
return
|
|
251
|
+
base, onto = _base(run)
|
|
252
|
+
old = run.tip()
|
|
253
|
+
if base and _origin_has_commits(run.pr, old, onto):
|
|
254
|
+
_keep_published_history(run, old, base, onto)
|
|
255
|
+
return
|
|
256
|
+
tree = git(".", "rev-parse", f"{old}^{{tree}}").stdout.strip()
|
|
257
|
+
git(".", "update-ref", "-m", "implement-spec: recovery point", RECOVERY, old)
|
|
258
|
+
ledger(f"Recovery point: {run.pr} was at {old} with the tree {tree} before the rebase and the tidy; "
|
|
259
|
+
f"{RECOVERY} holds that commit.")
|
|
260
|
+
git(".", "checkout", "-q", "--detach") # slot 1 is off the branch while the history is made
|
|
261
|
+
try:
|
|
262
|
+
commits = _new_history(run, tickets, old, base, onto)
|
|
263
|
+
_move_branch(run, commits, old, base, onto)
|
|
264
|
+
except Blocked as blocked:
|
|
265
|
+
raise Blocked(f"the rewrite: {blocked}; {run.pr} is unrewritten at its recovery point {old[:7]}") from None
|
|
266
|
+
finally:
|
|
267
|
+
git(".", "checkout", "-q", run.pr)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------- the final review and the fix batch
|
|
271
|
+
|
|
272
|
+
def _minor_findings() -> str:
|
|
273
|
+
"""One line per minor finding that a ticket review left for the final review, with its findings file."""
|
|
274
|
+
lines = []
|
|
275
|
+
for path in sorted(OUT.glob("[0-9]*/findings-*.json")):
|
|
276
|
+
lines += [f"- {path}: {_title(finding)}" for finding in json.loads(path.read_text())
|
|
277
|
+
if finding["severity"] == "minor"]
|
|
278
|
+
return "\n".join(dict.fromkeys(lines))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_review_is_due(run: Run, final: dict, tip: str) -> None:
|
|
282
|
+
"""Only a tip the driver made gets a review: the tidied tip, then the tip of the validated fix batch."""
|
|
283
|
+
if final["round"] >= ROUNDS:
|
|
284
|
+
raise Blocked(f"the two review rounds are done and the tip {tip[:7]} of {run.pr} is not the reviewed tip "
|
|
285
|
+
f"{final['reviewed'][:7]}")
|
|
286
|
+
made = final["batch"] or final["tidied"]
|
|
287
|
+
if tip != made:
|
|
288
|
+
raise Blocked(f"the tip {tip[:7]} of {run.pr} is not a tip the driver made: only {made[:7]} gets a review, "
|
|
289
|
+
f"so the run goes on when {run.pr} is at {made[:7]} again")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _record_review(state: dict, tip: str, to_fix: list[dict], evidence: list[str]) -> None:
|
|
293
|
+
"""The open findings of the first round start the fix batch; those of the second become one question each."""
|
|
294
|
+
record, final = state["run"], state["run"]["final"]
|
|
295
|
+
first_round = final["round"] == 0
|
|
296
|
+
final.update(reviewed=tip, round=final["round"] + 1, open=to_fix if first_round else [])
|
|
297
|
+
if not first_round:
|
|
298
|
+
driver_decisions.stop(record, KIND, "", [_title(finding) for finding in to_fix], evidence)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _settle_review(run: Run, done: int, tip: str, covered: str, found: list[dict]) -> None:
|
|
302
|
+
"""An accepted review of the range `covered`, after `done` earlier rounds: its findings go to their file, the
|
|
303
|
+
ledger and state.json."""
|
|
304
|
+
folder = OUT / FINAL
|
|
305
|
+
file = folder / f"findings-{done}.json"
|
|
306
|
+
file.write_text(json.dumps(found, indent=1) + "\n")
|
|
307
|
+
for finding in found:
|
|
308
|
+
ledger(f"Final review round {done + 1}: {finding['severity']} finding of kind {finding['kind']} at "
|
|
309
|
+
f"{finding['file']}:{finding['line']}: {finding['title']}.")
|
|
310
|
+
to_fix = [finding for finding in found if finding["severity"] in driver_review.OPEN]
|
|
311
|
+
evidence = [str(file), str(folder / f"final-reviewer-{done}.json")]
|
|
312
|
+
run.state.update(lambda state: _record_review(state, tip, to_fix, evidence))
|
|
313
|
+
ledger(f"Final review round {done + 1}: the range {covered} is reviewed, the reviewed tip is {tip[:7]}, and "
|
|
314
|
+
f"{len(to_fix)} critical or important finding{'s are' if len(to_fix) != 1 else ' is'} open.")
|
|
315
|
+
if to_fix and done:
|
|
316
|
+
ledger(f"End of run: {driver_decisions.decides(KIND, len(to_fix))} in {OUT / 'decisions.json'}: a third "
|
|
317
|
+
"review round is never started.")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _review(run: Run, final: dict, tip: str) -> None:
|
|
321
|
+
"""One round of the final review on `tip`: the full review, or after it the scoped one."""
|
|
322
|
+
done, folder = final["round"], OUT / FINAL
|
|
323
|
+
values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['reviewed'] or final['onto']}..{tip}",
|
|
324
|
+
"gate": str(folder / f"gate-{tip[:7]}.log"), "review_diff": str(REVIEW_DIFF),
|
|
325
|
+
"ledger": str(OUT / "ledger.md"), "minors": _minor_findings(), "reviewed": str(final["reviewed"]),
|
|
326
|
+
"rulings": "\n".join(driver_decisions.rulings(run.state.read()["run"]["questions"])),
|
|
327
|
+
"findings": str(folder / f"findings-{done - 1}.json")}
|
|
328
|
+
# A locked file that differs from the tidied tip is a critical finding. The driver made that tip, and since
|
|
329
|
+
# the tickets' own checks only the conflict agent of the rebase ran, which has the same check.
|
|
330
|
+
found = driver_review.locked_changes(".", final["tidied"], _locked_files(run))
|
|
331
|
+
with during("the final review"):
|
|
332
|
+
report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent(
|
|
333
|
+
"final-reviewer", FINAL, ".", values, notes=["final-scoped"] if done else [], fix_round=done))
|
|
334
|
+
_settle_review(run, done, tip, values["range"], found + report["findings"])
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fix_commit_message(commit: str, report: dict) -> str:
|
|
338
|
+
"""`fix(NN): ` and the fixer's own message; NN is the ticket the report gives for the commit."""
|
|
339
|
+
owner = next(one["ticket"] for one in report["commits"] if commit.startswith(one["sha"]))
|
|
340
|
+
words = driver_tidy.text(commit)
|
|
341
|
+
return words if words.startswith(f"fix({owner}): ") else f"fix({owner}): {words}"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _undo_rejected_batch(run: Run, tip: str) -> str:
|
|
345
|
+
"""Move the PR branch back to the reviewed tip `tip`: commits the driver did not validate get no review and
|
|
346
|
+
no push. The files stay in slot 1, where the next run stops on them as uncommitted changes.
|
|
347
|
+
Returns "" or, for the error message, why the branch did not move."""
|
|
348
|
+
left = run.tip()
|
|
349
|
+
if left != tip and (error := driver_merge.land(run.pr, tip, left)):
|
|
350
|
+
return f"; {run.pr} stays at {left[:7]}: {error}"
|
|
351
|
+
if left != tip:
|
|
352
|
+
commits = f"the commits of the batch end at {left} and stay in the reflog"
|
|
353
|
+
else:
|
|
354
|
+
commits = "the fixer made no commit"
|
|
355
|
+
ledger(f"End of run: the fix batch was rejected, so the driver moved {run.pr} back to its reviewed tip "
|
|
356
|
+
f"{tip[:7]}; {commits}. The files of the fixer stay in slot 1 as they are.")
|
|
357
|
+
return ""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _run_fixer(run: Run, tickets: list[Ticket], final: dict, tip: str) -> dict:
|
|
361
|
+
"""Run the fixer on the PR branch in slot 1 and return its report. A rejected batch is undone."""
|
|
362
|
+
folder = OUT / FINAL
|
|
363
|
+
values = {**_prompt_values(run), "start_sha": tip, "findings": str(folder / f"findings-{final['round'] - 1}.json"),
|
|
364
|
+
"locked": " ".join(_locked_files(run)),
|
|
365
|
+
"findings_list": "\n".join(f"- {finding['severity']}: {_title(finding)}" for finding in final["open"])}
|
|
366
|
+
try:
|
|
367
|
+
return run.agent("fixer", FINAL, ".", values, template="final-fixer", fix_round=final["round"],
|
|
368
|
+
check={"owners": [ticket.number for ticket in tickets]})
|
|
369
|
+
except Blocked as blocked:
|
|
370
|
+
raise Blocked(f"{blocked}{_undo_rejected_batch(run, tip)}") from None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _fix_batch(run: Run, tickets: list[Ticket], final: dict, tip: str) -> None:
|
|
374
|
+
"""Run the one fix batch, then name its commits `fix(NN)`."""
|
|
375
|
+
with during("the fix batch"):
|
|
376
|
+
report = _run_fixer(run, tickets, final, tip)
|
|
377
|
+
# The commits are named before the gate and the scoped review see them: nothing is rewritten after a review.
|
|
378
|
+
made = git(".", "rev-list", "--reverse", f"{tip}..HEAD").stdout.split()
|
|
379
|
+
named = driver_tidy.rewrite(tip, [(commit, _fix_commit_message(commit, report)) for commit in made])
|
|
380
|
+
if not driver_tidy.same_tree(named[-1], made[-1]):
|
|
381
|
+
raise Blocked(f"the named commits do not have the tree of {made[-1][:7]}")
|
|
382
|
+
# Slot 1 has the branch checked out: the new tip has the same tree, so the index and the files stay right.
|
|
383
|
+
if error := driver_merge.land(run.pr, named[-1], made[-1]):
|
|
384
|
+
raise Blocked(error)
|
|
385
|
+
run.state.update(lambda state: state["run"]["final"].update(open=[], batch=named[-1]))
|
|
386
|
+
subjects = ", ".join(f"{commit[:7]} {driver_tidy.text(commit).splitlines()[0]}" for commit in named)
|
|
387
|
+
ledger(f"Fix batch of the final review: {len(named)} commit{'s' if len(named) > 1 else ''} on {tip[:7]}: "
|
|
388
|
+
f"{subjects}.")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _review_until_reviewed(run: Run, tickets: list[Ticket]) -> None:
|
|
392
|
+
"""Gate and review the tip, and run the fix batch, until the tip is the reviewed tip."""
|
|
393
|
+
while True:
|
|
394
|
+
final, tip = run.state.read()["run"]["final"], run.tip()
|
|
395
|
+
if final["reviewed"] != tip:
|
|
396
|
+
_check_review_is_due(run, final, tip)
|
|
397
|
+
with during("the gate"):
|
|
398
|
+
run.gate(FINAL, ".", tip)
|
|
399
|
+
_review(run, final, tip)
|
|
400
|
+
elif final["open"]:
|
|
401
|
+
_fix_batch(run, tickets, final, tip)
|
|
402
|
+
else:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------- the push
|
|
407
|
+
|
|
408
|
+
def _name(run: Run) -> str:
|
|
409
|
+
"""The title of the PR: the first heading of the spec, else the PR branch."""
|
|
410
|
+
spec = Path(run.args.spec)
|
|
411
|
+
lines = spec.read_text(errors="replace").splitlines() if spec.is_file() else []
|
|
412
|
+
return next((line[2:].strip() for line in lines if line.startswith("# ")), run.pr)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _facts(run: Run, final: dict, tickets: list[Ticket]) -> str:
|
|
416
|
+
"""The end of the PR body: the ticket-to-commit map, the fix commits, the rulings and the review coverage."""
|
|
417
|
+
titles, tip = {ticket.number: ticket.title for ticket in tickets}, final["reviewed"]
|
|
418
|
+
fixes = git(".", "log", "--reverse", "--format=- %h %s", f"{final['tidied']}..{tip}").stdout.strip()
|
|
419
|
+
rulings = driver_decisions.rulings(run.state.read()["run"]["questions"])
|
|
420
|
+
return driver_agents.render("pr-body", {
|
|
421
|
+
"map": "\n".join(f"- {number}: {commit[:7]} {titles[number]}" for number, commit in final["commits"].items()),
|
|
422
|
+
"fixes": fixes or "None.", "rulings": "\n".join(f"- {ruling}" for ruling in rulings) or "None.",
|
|
423
|
+
"tip": tip, "onto": final["onto"], "tidied": final["tidied"],
|
|
424
|
+
"scoped": f"{final['tidied']}..{tip}" if fixes else "not needed"})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _write_body(run: Run, final: dict, tickets: list[Ticket]) -> Path:
|
|
428
|
+
"""Write the PR body: what the pr-writer wrote with the method of the pr skill, then the facts of the run. A body
|
|
429
|
+
written for the reviewed tip is kept, so a PR that failed to open costs no second agent."""
|
|
430
|
+
body, tip = OUT / FINAL / "pr-body.md", final["reviewed"]
|
|
431
|
+
if final["body"] == tip and body.is_file():
|
|
432
|
+
return body
|
|
433
|
+
method, facts = pr_skill(), _facts(run, final, tickets)
|
|
434
|
+
if not method:
|
|
435
|
+
raise Blocked("the pr skill is missing: the PR body follows it; install it with "
|
|
436
|
+
"`npx skills add mattpocock/skills --skill pr`")
|
|
437
|
+
values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['onto']}..{tip}",
|
|
438
|
+
"gate": str(OUT / FINAL / f"gate-{tip[:7]}.log"), "pr_skill": str(method), "facts": facts}
|
|
439
|
+
with during("the PR body"):
|
|
440
|
+
report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent("pr-writer", FINAL, ".", values))
|
|
441
|
+
body.write_text(f"{report['body'].strip()}\n\n{facts}")
|
|
442
|
+
run.state.update(lambda state: state["run"]["final"].update(body=tip))
|
|
443
|
+
return body
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _open_pr(run: Run, final: dict, tickets: list[Ticket]) -> str:
|
|
447
|
+
"""Open the PR, ready for review, and return its URL."""
|
|
448
|
+
body = _write_body(run, final, tickets)
|
|
449
|
+
made = subprocess.run(["gh", "pr", "create", "--base", final["base"], "--head", run.pr, "--title", _name(run),
|
|
450
|
+
"--body-file", str(body)], capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
451
|
+
if made.returncode:
|
|
452
|
+
raise Blocked(f"gh pr create failed: {(made.stderr or made.stdout).strip()}")
|
|
453
|
+
url = (made.stdout.split() or [""])[-1]
|
|
454
|
+
run.state.update(lambda state: state["run"]["final"].update(pr=url))
|
|
455
|
+
ledger(f"The PR of {run.pr} into {final['base']} is open: {url}.")
|
|
456
|
+
return url
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _push(run: Run, final: dict, tickets: list[Ticket]) -> list[str]:
|
|
460
|
+
"""Push the reviewed tip to origin, once and with no force, then open the PR, once. Returns the lines for the
|
|
461
|
+
operator."""
|
|
462
|
+
tip = final["reviewed"]
|
|
463
|
+
if tip != run.tip() or tip not in final["green_gates"]:
|
|
464
|
+
raise Blocked(f"the reviewed tip {tip[:7]} is not the tip of {run.pr} with a green gate record: nothing is "
|
|
465
|
+
"pushed")
|
|
466
|
+
if not final["base"]:
|
|
467
|
+
raise Blocked(f"the end of run tidied {run.pr} when the repository had no origin remote, so it is on no base "
|
|
468
|
+
"branch of origin: nothing is pushed")
|
|
469
|
+
if not shutil.which("gh"):
|
|
470
|
+
raise Blocked("gh is not on PATH: nothing is pushed")
|
|
471
|
+
if final["pushed"] != tip:
|
|
472
|
+
# The commit, not the branch name: what leaves the machine is the tip the review covered.
|
|
473
|
+
sent = git(".", "push", "-q", "origin", f"{tip}:refs/heads/{run.pr}")
|
|
474
|
+
if sent.returncode:
|
|
475
|
+
raise Blocked(f"git push origin {run.pr} failed: {sent.stderr.strip()}")
|
|
476
|
+
run.state.update(lambda state: state["run"]["final"].update(pushed=tip))
|
|
477
|
+
ledger(f"Pushed {run.pr} at {tip[:7]} to origin.")
|
|
478
|
+
url = final["pr"] or _open_pr(run, final, tickets)
|
|
479
|
+
return [f"Pushed {run.pr} at {tip} to origin.", f"PR: {url}"]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def again(argv: list[str]) -> str:
|
|
483
|
+
"""The command of this run with `--push` and without its answers: an answer is given once."""
|
|
484
|
+
kept, skip = [], False
|
|
485
|
+
for word in argv:
|
|
486
|
+
if not (skip or word.startswith("--answer")):
|
|
487
|
+
kept.append(word)
|
|
488
|
+
skip = word == "--answer"
|
|
489
|
+
return shlex.join(["sh", str(driver_agents.SKILL / "run.sh"), *kept, "--push"])
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _close(run: Run, tickets: list[Ticket]) -> list[str]:
|
|
493
|
+
"""The tip is reviewed and its gate is green: push when the run may. Returns the lines for the operator."""
|
|
494
|
+
record = run.state.read()["run"]
|
|
495
|
+
answers = [question["answer"] for question in record["questions"] if question["kind"] == KIND]
|
|
496
|
+
if None in answers:
|
|
497
|
+
return [] # parked: the driver prints the questions
|
|
498
|
+
if kept := answers.count("fix"):
|
|
499
|
+
raise Blocked(f"the operator keeps {kept} finding{'s' if kept > 1 else ''} of the final review open (fix): "
|
|
500
|
+
"nothing is pushed")
|
|
501
|
+
final = record["final"]
|
|
502
|
+
if run.args.push or run.args.unattended:
|
|
503
|
+
return _push(run, final, tickets)
|
|
504
|
+
if final["base"]:
|
|
505
|
+
how = f"To push {run.pr} and open the PR, run: {again(run.args.argv)}"
|
|
506
|
+
else:
|
|
507
|
+
how = "The repository has no origin remote."
|
|
508
|
+
return [f"Branch tip: {run.tip()}", f"Reviewed tip: {final['reviewed']}", f"Nothing is pushed. {how}"]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _unmerged_tickets(state: dict) -> str:
|
|
512
|
+
""""" or why the end-of-run work does not start. A ticket that is not merged can still merge after an answer,
|
|
513
|
+
and its branch starts from the history as it is: a tidy would rewrite its branch point."""
|
|
514
|
+
waiting = [number for number, entry in state["tickets"].items() if entry["phase"] != "merged"]
|
|
515
|
+
return f"these tickets are not merged: {', '.join(waiting)}" if waiting else ""
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def run(run: Run, tickets: list[Ticket]) -> tuple[bool, list[str]]:
|
|
519
|
+
"""Do the end of the run, from where state.json says it is. Call it only when no slot runs a ticket.
|
|
520
|
+
Returns (the end of run is BLOCKED, the lines the operator reads last)."""
|
|
521
|
+
state = run.state.read()
|
|
522
|
+
reason, before = _unmerged_tickets(state), state["run"]["final"]["skipped"]
|
|
523
|
+
run.state.update(lambda new: new["run"]["final"].update(skipped=reason))
|
|
524
|
+
if reason:
|
|
525
|
+
if reason != before:
|
|
526
|
+
ledger(f"End-of-run work skipped: {reason}.")
|
|
527
|
+
return False, []
|
|
528
|
+
try:
|
|
529
|
+
_rewrite_once(run, tickets)
|
|
530
|
+
_review_until_reviewed(run, tickets)
|
|
531
|
+
return False, _close(run, tickets)
|
|
532
|
+
except Blocked as blocked:
|
|
533
|
+
ledger(f"End of run: BLOCKED at {blocked}.")
|
|
534
|
+
return True, [f"End of run: BLOCKED at {blocked}"]
|