Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ Comment commands (`/retry <issue>`, `/review <pr>`) 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
Expand Down
10 changes: 10 additions & 0 deletions deploy/github-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
25 changes: 22 additions & 3 deletions devloop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)
16 changes: 16 additions & 0 deletions devloop/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/")]
Expand Down
9 changes: 9 additions & 0 deletions devloop/forge/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions devloop/forge/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"])
Expand Down
11 changes: 11 additions & 0 deletions devloop/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/test_devloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading