#!/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 [--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 /.paperclip/worktrees/; 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)