feat(labctl): land a PR/CR from the agent surface (pr merge) #48

Merged
dominik.polakovics merged 1 commit from afk/37 into main 2026-07-08 11:47:00 +02:00

What

Adds labctl pr merge <n> so an AFK agent can land a mergeable PR/CR — the routine end-of-task step that until now always needed a human. The Tracker seam grows MergePull(number) (PullRef, error), reachable over the run-token agent surface (repo-scoped by the run row), on both bindings.

Closes #37

How

  • Seam: Tracker.MergePull + tracker.ErrMergeRejected (carries the backend's own words verbatim).
  • Shared service: the ADR-0011 CR-merge/close orchestration is lifted out of the httpapi handler into internal/crmerge.Service, which both the operator route (POST /crs/{n}/merge) and the built-in tracker's MergePull call. The per-CR mutex (merge and close serialize), the cancellation-immune git window, author identity (repo's real identity else settings, never a bot), the per-op credential, Closes #N closure, and the cr.changed/issue.changed events are preserved byte-for-byte — the operator route keeps its exact M6 status codes.
  • Forge bindings (Forgejo now, GitHub for parity): a real merge action performed under the server's forge token — no forge credential ever reaches the session (ADR-0014). Fixed merge method (the forge applies its own strategy and enforces branch protection); the head branch is never deleted; a refusal (required check red, protected base, conflict) surfaces verbatim as ErrMergeRejected → 409; an already-merged PR is a convergent no-op success; a 404 and a throttle stay their own typed conditions (404 / retryable), not a permanent "merge refused".
  • Agent API + labctl: POST /agent/v1/prs/{n}/merge + the labctl client/CLI; a refusal is exit 1 with the message on stderr.
  • Docs: ADR-0024 records the decision; ADR-0011 gets a pointer; the agent brief and CONTEXT.md note the new command.

Acceptance criteria

  • builtin agent merge via the existing CR-merge path (ff/merge-commit), closing Closes #N and emitting the same events as the operator merge — TestAgentBuiltinPRMergeFullLoop.
  • forge agent merge via a server-side forge merge call, no forge token in the session — forge/github MergePull tests + agent handler test.
  • head branch still exists immediately after a successful merge — asserted for builtin (rev-parse) and forge (no branch-delete flag).
  • a refused merge fails with the backend's own message verbatim + non-zero exit; nothing half-recorded (CR stays open).
  • re-merging an already-merged PR/CR is a convergent no-op success.
  • merge reachable only over the run-token agent surface with repo scope enforced (derived from the run row, never the URL).

Testing

go build ./..., go vet ./..., gofmt clean, and the full suite green (the only failures are the pre-existing environmental internal/tmuxx tests — tmux can't start sessions in this sandbox; that package doesn't touch this change). New tests cover each binding's MergePull, the agent handler, the labctl CLI, and the end-to-end builtin agent merge loop. A multi-agent adversarial review ran over the diff; its three confirmed findings (rate-limit/internal-error classification, forge head-survival coverage) are fixed in this branch.

## What Adds `labctl pr merge <n>` so an AFK agent can **land** a mergeable PR/CR — the routine end-of-task step that until now always needed a human. The `Tracker` seam grows `MergePull(number) (PullRef, error)`, reachable over the run-token agent surface (repo-scoped by the run row), on **both** bindings. Closes #37 ## How - **Seam**: `Tracker.MergePull` + `tracker.ErrMergeRejected` (carries the backend's own words verbatim). - **Shared service**: the ADR-0011 CR-merge/close orchestration is lifted out of the httpapi handler into `internal/crmerge.Service`, which **both** the operator route (`POST /crs/{n}/merge`) and the built-in tracker's `MergePull` call. The per-CR mutex (merge *and* close serialize), the cancellation-immune git window, author identity (repo's real identity else settings, never a bot), the per-op credential, `Closes #N` closure, and the `cr.changed`/`issue.changed` events are preserved byte-for-byte — the operator route keeps its exact M6 status codes. - **Forge bindings** (Forgejo now, GitHub for parity): a real merge action performed under the **server's** forge token — no forge credential ever reaches the session (ADR-0014). Fixed merge method (the forge applies its own strategy and enforces branch protection); the head branch is never deleted; a refusal (required check red, protected base, conflict) surfaces verbatim as `ErrMergeRejected` → 409; an already-merged PR is a convergent no-op success; a 404 and a throttle stay their own typed conditions (404 / retryable), not a permanent "merge refused". - **Agent API + labctl**: `POST /agent/v1/prs/{n}/merge` + the labctl client/CLI; a refusal is exit 1 with the message on stderr. - **Docs**: ADR-0024 records the decision; ADR-0011 gets a pointer; the agent brief and CONTEXT.md note the new command. ## Acceptance criteria - [x] builtin agent merge via the existing CR-merge path (ff/merge-commit), closing `Closes #N` and emitting the same events as the operator merge — `TestAgentBuiltinPRMergeFullLoop`. - [x] forge agent merge via a server-side forge merge call, no forge token in the session — forge/github `MergePull` tests + agent handler test. - [x] head branch still exists immediately after a successful merge — asserted for builtin (rev-parse) and forge (no branch-delete flag). - [x] a refused merge fails with the backend's own message verbatim + non-zero exit; nothing half-recorded (CR stays open). - [x] re-merging an already-merged PR/CR is a convergent no-op success. - [x] merge reachable only over the run-token agent surface with repo scope enforced (derived from the run row, never the URL). ## Testing `go build ./...`, `go vet ./...`, `gofmt` clean, and the full suite green (the only failures are the pre-existing environmental `internal/tmuxx` tests — tmux can't start sessions in this sandbox; that package doesn't touch this change). New tests cover each binding's `MergePull`, the agent handler, the labctl CLI, and the end-to-end builtin agent merge loop. A multi-agent adversarial review ran over the diff; its three confirmed findings (rate-limit/internal-error classification, forge head-survival coverage) are fixed in this branch.
feat(labctl): land a PR/CR from the agent surface via a shared merge service
All checks were successful
ci / native (pull_request) Successful in 4m23s
d64a3a8bbf
Add `labctl pr merge <n>` and grow the Tracker seam with MergePull, so an AFK
agent can land a mergeable PR/CR entirely over the run-token surface — no forge
token ever reaches the session.

- Tracker seam: MergePull(number) (PullRef, error) + ErrMergeRejected sentinel.
- Lift the ADR-0011 CR-merge/close orchestration out of the httpapi handler
  into a shared internal/crmerge.Service that BOTH the operator route and the
  built-in tracker's MergePull call — preserving the per-CR mutex, the
  cancellation-immune git window, author identity, per-op credential, Closes #N
  closure, and cr.changed/issue.changed events byte-for-byte.
- Forgejo/GitHub bindings gain a real merge action performed under the server's
  forge token; fixed merge method (the forge enforces its own strategy and
  protection); a refusal surfaces verbatim as ErrMergeRejected (a throttle and
  a 404 stay their own typed conditions); an already-merged PR is a convergent
  no-op success.
- Agent route POST /agent/v1/prs/{n}/merge (repo-scoped by the run row) plus the
  labctl client method and CLI command; a refusal is a 409 / exit 1 verbatim.
- ADR-0024 records the decision; ADR-0011 gains a pointer.

Closes #37

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011z1HjhQYV5i7PRbyNYofi6
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!48
No description provided.