From 4dfe19286f1c8b4e965be5871b1365888ebefe3b Mon Sep 17 00:00:00 2001 From: TEMP Date: Sat, 12 Sep 2026 16:46:57 -0400 Subject: [PATCH] feat: close out issues when their devloop PR merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A human-merged devloop PR now completes its issue's lifecycle: ledger completion entry (not counted as an attempt) + issue close. Same carve-out as /retry's close_pr — the merge IS the human's verdict; devloop executes it, never judges its own work. - ledger: MERGED marker + merged() producer, outside MARKERS - forge: complete_issue() (base docstring cites the precedent), gh impl - core: handle_merge() — devloop/issue-N branch mapping, no-op otherwise - cli: devloop merged (GITHUB_EVENT_PATH, silent no-op off-CI) - workflow template: pull_request closed trigger, YAML-gated to merged devloop branches - tests, CONTEXT.md (Closeout), README guardrail note, CHANGELOG --- CHANGELOG.md | 7 ++++++ CONTEXT.md | 11 +++++++++ README.md | 4 ++- deploy/github-actions.yml | 10 ++++++++ devloop/cli.py | 25 ++++++++++++++++--- devloop/core.py | 16 ++++++++++++ devloop/forge/base.py | 9 +++++++ devloop/forge/github.py | 3 +++ devloop/ledger.py | 11 +++++++++ tests/test_devloop.py | 51 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 143 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea87947..5fe3931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ patch bumps fix behavior bugs. Tag = release. - Fixed build prompts using `.format()`: braces in an issue body (`def f(): return {'a': 1}`) crashed the build before the agent ran; substitution is now replace-based, like review and repair +- Merge closeout: a human-merged devloop PR (`devloop/issue-N`) posts a + `devloop PR merged` ledger entry and closes its issue — no more + ghost issues reopened by sweeps after their PR merged. New + `forge.complete_issue` (carve-out documented in the base class, same + precedent as `/retry`'s close_pr), `devloop merged` CLI, and a + `pull_request: closed` trigger in the workflow template (YAML-gated to + merged devloop branches, so other merges cost nothing) ## v0.3.0 — repair phase diff --git a/CONTEXT.md b/CONTEXT.md index 223c804..978eb59 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,6 +35,17 @@ never raises: every failure path posts its own ledger comment and returns an bug in one place, not a forgotten except clause in a caller. Review is NOT delivery — see Review. +### Closeout + +Closing the loop on a build: a human merged the devloop PR, so its issue's +lifecycle is complete. The **merge handler** (`core.handle_merge`, fired only +from a real forge merge event — never agent output) posts the +`devloop PR merged #N` ledger entry and closes the issue via +`forge.complete_issue`. Not a guardrail breach — same carve-out as `/retry`'s +close_pr: the human's merge IS the judgment that the work is done; devloop is +executing that act, not judging its own work. The completion marker is a +ledger record, never an attempt (see Ledger). + ### Review Finding what's wrong before a human merges. The **review module** diff --git a/README.md b/README.md index a8e0c90..ecbb8ad 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ Comment commands (`/retry `, `/review `) are the one exception, and they are safe: devloop executes them **for** an authorized human (the author is access-gated before anything happens), and closing a stale PR on `/retry` is executing that human's explicit sanction — not the agent judging -its own work. Labels create work; commands re-fire it. Trigger labels and +its own work. The same carve-out closes an issue whose devloop PR a human +just merged: the merge IS the human's verdict; devloop only records it. +Labels create work; commands re-fire it. Trigger labels and `ready-for-agent` are mutually exclusive. Humans merge. ## Comment commands diff --git a/deploy/github-actions.yml b/deploy/github-actions.yml index 6e5659d..5b9f228 100644 --- a/deploy/github-actions.yml +++ b/deploy/github-actions.yml @@ -6,6 +6,8 @@ on: types: [labeled] issue_comment: types: [created] + pull_request: + types: [closed] workflow_dispatch: schedule: - cron: "*/30 * * * *" # self-healing: pending labels survive skipped/failed runs @@ -23,6 +25,9 @@ jobs: github.event.label.name == 'ai-build' || github.event.label.name == 'ai-remove' || github.event.label.name == 'ai-task' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'devloop/')) || (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/')) runs-on: ubuntu-latest permissions: @@ -45,3 +50,8 @@ jobs: run: devloop command env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Close out merged devloop PR's issue + if: github.event_name == 'pull_request' + run: devloop merged + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/devloop/cli.py b/devloop/cli.py index f85f949..65a1aea 100644 --- a/devloop/cli.py +++ b/devloop/cli.py @@ -10,7 +10,7 @@ from . import __version__ from .config import load -from .core import handle_command, run_once +from .core import handle_command, handle_merge, run_once from .forge import get_forge from .review import review_pr from .runtime import get_runtime @@ -102,6 +102,24 @@ def cmd_command(_args: argparse.Namespace) -> None: print(f"command result: {outcome}") +def cmd_merged(_args: argparse.Namespace) -> None: + """Close out an issue whose devloop PR a human just merged. Runs from + CI's pull_request(closed, merged) event; silent exit otherwise (zero + token spend — no agent run here, just forge calls).""" + path = _args.event or os.environ.get("GITHUB_EVENT_PATH", "") + if not path or not Path(path).exists(): + return + ev = json.loads(Path(path).read_text()) + pr = ev.get("pull_request") or {} + if not pr.get("merged"): + return + cfg = load() + forge, _runtime = _runtime(cfg) + out = handle_merge(cfg, forge, pr["number"], (pr.get("head") or {}).get("ref") or "") + if out: + print(f"merge result: {out}") + + def cmd_watch(_args: argparse.Namespace) -> None: cfg = load() print(f"watching {cfg.repo} every {cfg.pipeline.poll_seconds}s — ctrl-c to stop") @@ -119,14 +137,15 @@ def main() -> None: ap.add_argument("--version", action="version", version=__version__) sub = ap.add_subparsers(required=True) for name, fn in [("init", cmd_init), ("once", cmd_once), ("watch", cmd_watch), - ("spec", cmd_spec), ("review", cmd_review), ("command", cmd_command)]: + ("spec", cmd_spec), ("review", cmd_review), ("command", cmd_command), + ("merged", cmd_merged)]: s = sub.add_parser(name) s.set_defaults(fn=fn) if name == "spec": s.add_argument("issue", type=int, help="issue number to refine") if name == "review": s.add_argument("pr", type=int, help="PR number to review") - if name == "command": + if name == "command" or name == "merged": s.add_argument("--event", default="", help="path to GitHub event payload (default $GITHUB_EVENT_PATH)") args = ap.parse_args() args.fn(args) diff --git a/devloop/core.py b/devloop/core.py index 95fc36c..34f86f0 100644 --- a/devloop/core.py +++ b/devloop/core.py @@ -146,6 +146,22 @@ def handle_command(cfg: Config, forge: Forge, runtime: AgentRuntime, return "failed" +def handle_merge(cfg: Config, forge: Forge, pr_number: int, head_branch: str) -> str | None: + """A devloop PR was merged by a human: ledger the completion, close the + issue. Same carve-out as close_pr — the merge IS the human's sanction; + this fires only from a real forge merge event, never agent output. + None = not a devloop PR (caller's YAML gate should already know).""" + if not head_branch.startswith("devloop/issue-"): + return None + try: + n = int(head_branch.rsplit("-", 1)[-1]) + except ValueError: + return None + ledger.merged(forge, n, pr_number) + forge.complete_issue(n) + return f"completed issue #{n}" + + def run_once(cfg: Config, forge: Forge, runtime: AgentRuntime) -> list[Outcome]: open_heads = forge.open_pr_head_branches() devloop_heads = [h for h in open_heads if h.startswith("devloop/")] diff --git a/devloop/forge/base.py b/devloop/forge/base.py index 7522fd8..8376782 100644 --- a/devloop/forge/base.py +++ b/devloop/forge/base.py @@ -74,6 +74,15 @@ def close_pr(self, pr_number: int, reason: str) -> None: access-gates the author first.""" raise NotImplementedError + def complete_issue(self, number: int) -> None: + """Close an issue whose devloop PR a human merged. NOT in + HUMAN_ONLY — deliberately, same precedent as close_pr: the human + merging the PR IS the judgment that the work is done; devloop is + executing that act, not judging its own work. Guarded one layer + down: handle_merge is the only caller, and it fires only from a + real forge merge event — never from agent output.""" + raise NotImplementedError + def pr_files(self, pr_number: int) -> list[str]: """Files touched by an open PR (for the delivery conflict gate).""" raise NotImplementedError diff --git a/devloop/forge/github.py b/devloop/forge/github.py index db63ca9..660b22f 100644 --- a/devloop/forge/github.py +++ b/devloop/forge/github.py @@ -200,6 +200,9 @@ def close_pr(self, pr_number: int, reason: str) -> None: _run(["gh", "pr", "close", str(pr_number), "-R", self.repo, "--comment", reason, "--delete-branch"]) + def complete_issue(self, number: int) -> None: + _run(["gh", "issue", "close", str(number), "-R", self.repo]) + def pr_files(self, pr_number: int) -> list[str]: out = _run(["gh", "pr", "view", str(pr_number), "-R", self.repo, "--json", "files", "--jq", "[.files[].path]"]) diff --git a/devloop/ledger.py b/devloop/ledger.py index 6e9ff74..1b4ff50 100644 --- a/devloop/ledger.py +++ b/devloop/ledger.py @@ -22,6 +22,10 @@ RESET = "build reset by" +# Completion (not a failure kind — deliberately outside MARKERS, so count() +# never mistakes a merge for an attempt): a human merged the devloop PR. +MERGED = "devloop PR merged" + # Tail truncation lives in runtime.TAIL (one policy, shared with every # place agent output is rendered). @@ -46,6 +50,13 @@ def reset(forge: Forge, issue: Issue | int, reason: str = "") -> None: forge.comment(n, body) +def merged(forge: Forge, issue: Issue | int, pr_number: int) -> None: + """Post one completion entry: the human merged the devloop PR — the + issue's build lifecycle is done. On the record like every ledger entry.""" + n = issue.number if isinstance(issue, Issue) else issue + forge.comment(n, f"{MERGED} #{pr_number} — closing the issue") + + def count(forge: Forge, issue: Issue) -> int: """Past failed attempts, counted from the issue's own comment ledger — no extra state. Guards the scheduled sweeps against burning tokens on diff --git a/tests/test_devloop.py b/tests/test_devloop.py index 1d5a16b..c867a02 100644 --- a/tests/test_devloop.py +++ b/tests/test_devloop.py @@ -774,3 +774,54 @@ def test_version_bump(): test_version_bump() print("all checks passed") + + +def test_merged_pr_completes_issue(): + """A human-merged devloop PR closes its issue — executing the human's + merge sanction (same carve-out as close_pr), on the ledger record.""" + import devloop.core as core + from devloop.config import Config + from devloop.forge.base import Comment + + class F(Forge): + def __init__(self): + self.notes, self.completed = [], [] + + def comment(self, n, body): self.notes.append((n, body)) + def complete_issue(self, n): self.completed.append(n) + + forge = F() + cfg = Config(repo="o/r") + + # devloop PR merged → ledger entry + issue closed + out = core.handle_merge(cfg, forge, 55, "devloop/issue-9") + assert out == "completed issue #9" + assert forge.notes and forge.notes[0][1].startswith("devloop PR merged #55") + assert forge.completed == [9] + + # non-devloop branch → no-op (never touches a stranger's issue) + f2 = F() + assert core.handle_merge(cfg, f2, 56, "feature/x") is None + assert not f2.notes and not f2.completed + + # malformed devloop branch → no-op, not a crash + f3 = F() + assert core.handle_merge(cfg, f3, 57, "devloop/issue-") is None + assert not f3.completed + + # guardrail unchanged: close_issue stays human-only for all other callers + try: + forge.close_issue(9) + except GuardrailViolation: + pass + else: + raise AssertionError("close_issue must stay human-only") + + # ledger protocol: the completion marker is not counted as an attempt + class LF(Forge): + def comments(self, n): + return [Comment("x", "devloop PR merged #55 — closing the issue"), + Comment("x", "agent run FAILED — boom")] + + from devloop import ledger + assert ledger.count(LF(), 9) == 1 # merge ≠ an attempt; the failure is