137 lines
5.6 KiB
Python
Executable File
137 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Merge one task at a time, re-checked after the rebase.
|
|
|
|
bin/merge AUD-163 # check, then merge
|
|
bin/merge AUD-163 --check # do every check, merge nothing
|
|
|
|
The order is the point:
|
|
|
|
1. take the merge lock, so two tasks cannot merge at once
|
|
2. rebase the task's branch onto the project's base branch
|
|
3. run the task's own verify command in the worktree
|
|
4. run ocr over the rebased range; a critical or high finding refuses the merge,
|
|
and so does a review that could not run
|
|
5. merge, then run the verify command again on the base branch
|
|
6. write the verdict on the task, so the record lives with the work
|
|
|
|
A refusal is a decision for the person: the branch is left where it was.
|
|
"""
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib"))
|
|
import harness as h # noqa: E402
|
|
|
|
LOCK = "/tmp/paperclip-harness-merge.lock"
|
|
|
|
|
|
def main():
|
|
args = [a for a in sys.argv[1:]]
|
|
check_only = "--check" in args
|
|
args = [a for a in args if not a.startswith("--")]
|
|
if len(args) != 1:
|
|
h.say("usage: bin/merge <task> [--check]")
|
|
return 2
|
|
|
|
issue = h.find_issue(args[0])
|
|
ref = issue["identifier"]
|
|
worktree, branch, base = h.worktree_of(issue)
|
|
base_repo = os.path.dirname(worktree)
|
|
# The worktree lives under <repo>/.paperclip/worktrees/<branch>; the base
|
|
# checkout is the project folder itself.
|
|
if ".paperclip/worktrees" in base_repo:
|
|
base_repo = base_repo.split("/.paperclip/worktrees")[0]
|
|
|
|
h.say("%s %s" % (ref, issue["title"]))
|
|
h.say(" worktree %s" % worktree)
|
|
h.say(" branch %s onto %s" % (branch, base))
|
|
|
|
lock_file = open(LOCK, "w")
|
|
try:
|
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
raise h.Refused(6, "another merge is in progress; this one waits its turn")
|
|
|
|
# 1. Rebase, so the checks run on what would actually land.
|
|
code, out = h.run(["git", "-C", worktree, "rebase", base])
|
|
if code != 0:
|
|
h.run(["git", "-C", worktree, "rebase", "--abort"])
|
|
raise h.Refused(3, "rebase onto %s conflicts; a person decides:\n%s" % (base, out[-800:]))
|
|
h.say(" rebased onto %s" % base)
|
|
|
|
# 2. The task's own command decides whether it is finished.
|
|
cmd, source = h.verify_command(issue, worktree)
|
|
if cmd is None:
|
|
h.say(" no verify command: this would pass on the agent's word")
|
|
else:
|
|
code, out = h.run(["sh", "-c", cmd], cwd=worktree)
|
|
if code != 0:
|
|
h.verdict(issue, "## Merge refused: the verify command failed\n\n"
|
|
"`%s` (%s) exited %d in the task's worktree.\n\n```\n%s\n```"
|
|
% (cmd, source, code, out[-1500:]))
|
|
raise h.Refused(4, "verify command failed: %s" % cmd)
|
|
h.say(" verify ok: %s (%s)" % (cmd, source))
|
|
|
|
# 3. Review, on the rebased range.
|
|
report = h.ocr_review(worktree, base, "HEAD")
|
|
status = report.get("status")
|
|
reviewed = (report.get("summary") or {}).get("files_reviewed", 0)
|
|
if status == "skipped" and reviewed == 0:
|
|
# Nothing in the change is reviewable (a documentation-only diff, for
|
|
# one). That is not a failure, but it is not a review either, and the
|
|
# record has to say which.
|
|
h.say(" review saw nothing to review (0 files); recorded, not hidden")
|
|
elif status != "complete":
|
|
# "Could not run" is not a pass. Never merge what a check could not see.
|
|
h.verdict(issue, "## Merge refused: the review could not complete\n\n"
|
|
"ocr returned `%s` — %s" % (status, report.get("message") or "no reason given"))
|
|
raise h.Refused(5, "review could not complete (%s)" % status)
|
|
|
|
findings = h.blocking_findings(report)
|
|
if findings:
|
|
body = "## Merge refused: the review found %d blocking item(s)\n\n%s" % (
|
|
len(findings), "\n".join(h.describe(c) for c in findings))
|
|
h.verdict(issue, body)
|
|
h.say(body)
|
|
raise h.Refused(5, "review blocked the merge")
|
|
|
|
n = len(report.get("comments") or [])
|
|
if status == "complete":
|
|
h.say(" review ok: %d comment(s), none blocking" % n)
|
|
|
|
if check_only:
|
|
h.say(" checked only; nothing merged")
|
|
return 0
|
|
|
|
# 4. Merge, then prove the base branch still passes the task's command.
|
|
code, out = h.run(["git", "-C", base_repo, "merge", "--no-ff", branch,
|
|
"-m", "Merge %s: %s" % (ref, issue["title"])])
|
|
if code != 0:
|
|
raise h.Refused(8, "merge failed:\n%s" % out[-800:])
|
|
|
|
if cmd is not None:
|
|
code, out = h.run(["sh", "-c", cmd], cwd=base_repo)
|
|
if code != 0:
|
|
h.run(["git", "-C", base_repo, "reset", "--hard", "HEAD^"])
|
|
h.verdict(issue, "## Merge undone: the verify command failed on %s\n\n"
|
|
"`%s` exited %d after merging, so the merge was rolled back.\n\n```\n%s\n```"
|
|
% (base, cmd, code, out[-1500:]))
|
|
raise h.Refused(4, "verify failed on %s after merging; rolled back" % base)
|
|
h.say(" verify ok on %s after the merge" % base)
|
|
|
|
head = h.run(["git", "-C", base_repo, "rev-parse", "--short", "HEAD"])[1].strip()
|
|
review_line = ("%d comment(s), none blocking" % n) if status == "complete" \
|
|
else "nothing reviewable in the change (0 files)"
|
|
h.verdict(issue, "## Merged\n\n- branch `%s` → `%s` at `%s`\n"
|
|
"- verify: %s\n- review: %s"
|
|
% (branch, base, head, ("`%s` ok" % cmd) if cmd else "none agreed", review_line))
|
|
h.say(" merged into %s at %s" % (base, head))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
h.main_guard(main)
|