From b9eccbd66b2c70743e439d406e7f9625f6befe4d Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 20:49:24 -0400 Subject: [PATCH 01/13] docs(plan): change 0529 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md --- ...-change-backlink-when-the-change-is-arc.md | 2198 +++++++++++++++++ 1 file changed, 2198 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md diff --git a/docs/superpowers/plans/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md b/docs/superpowers/plans/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md new file mode 100644 index 000000000..dc5412646 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md @@ -0,0 +1,2198 @@ + +> ↩ **[Change 0529 — Repoint a merged PR's change backlink when the change is archived](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0529-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md)** + +# Repoint a Merged PR's Change Backlink on Archive — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. In this repository the plan is executed by `docket-build`. + +**Goal:** When close-out archives a change, rewrite the docket-owned `docket:backlink` block in the merged PR's description so it links to the archived record; the cleanup leg and the maintenance sweep retry a failed edit; `docket repository repair --pr-backlinks` repairs the PRs that are already broken, after a preview and a human `--yes`. + +**Architecture:** One new GitHub adapter primitive, `githubcli.Client.EditPullRequestBody`, edits one PR's body by number. It follows `RetargetPullRequest`'s probe→act→verify shape: an exact-revision gate, the body sent on stdin only, a fresh by-number re-read to verify. One new app file, `internal/app/pr_backlink_repoint.go`, owns the only decision about whether a PR body needs repointing (`planPRBacklinkRepoint`, keyed on whether the block already names the archive path) and the only edit sequence (`repointPRBacklink` / `applyPRBacklinkRepoint`). Close-out, cleanup, the sweep assessment, and the repair all call these; none of them keeps its own copy. Close-out and cleanup reach GitHub through a narrow `FinalizePRBody` seam that falls back to `deps.GitHub` when that value satisfies it, the same way `cleanupGit` falls back to `Planning.Client`. The sweep reads PR bodies once per invocation through the existing batched reader (`SweepPRBatchReader.ProbePRSet`). The repair gets its GitHub seam through `SetupDeps.GitHub`, and the CLI sets that field only for `--pr-backlinks`, so routine `repository` commands cannot read a PR body. + +**Tech Stack:** Go (`internal/githubcli`, `internal/app`, `internal/cli`), the protocol-faithful fake `gh` in `internal/githubcli/harness_integration_test.go`, real-git integration fixtures behind the `integration` build tag, POSIX shard runners under `tests/`. + +**Spec:** `docs/superpowers/specs/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-design.md` (on the `docket` branch; read it at `/Users/homer/dev/docket/.docket/docs/superpowers/specs/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-design.md`). + +## Global Constraints + +- **Best-effort, never blocking.** A failed or unknown GitHub edit never changes close-out's disposition or result. It becomes a `pr-backlink-pending` warning finding, and cleanup and the sweep retry it. +- **Idempotent.** A body whose backlink block already names the archive path is a no-op (no edit issued). A body with no backlink block is left untouched and never given one. +- **Only the docket-owned block changes.** Every authored byte outside ``…`` survives byte-for-byte, including CRLF line endings and the build-evidence block. Use the existing `document.Parse` / `PatchSet.ReplaceBlock` / `Apply` path (the same one `assemblePRBody` and `backlinkLegRetarget` use). +- **Redaction.** PR body bytes never appear in an argv element, a result field, a finding message, or a diagnostic. They travel to `gh` on stdin only (`--body-file -`). The only body-derived text that may appear in output is the backlink block's own generated interior line, in the repair preview. +- **Three outcomes for every probe.** A read error, a batch failure, a `Found=false` slot, or malformed markers is *unknown*: never a clean no-op and never authority to edit (learning `probe-error-is-not-clean-absence`). +- **Edit only under the exact revision.** An edit is issued only when the caller's expected revision equals the live one. Drift is `contended`, and the PR is not touched. +- **No new blocking gate.** Nothing in this change can stop a close-out, a cleanup, or a sweep. A stuck PR leg is a visible warning only. +- `repository repair` with no flag, `repository check`, and `repository prepare` make **no** GitHub PR-body reads. Only `repository repair --pr-backlinks` constructs a GitHub client. +- `repository.repair`'s catalog effects become `external-write metadata-write`. `finalize.closeout`'s become `external-write metadata-write`, because close-out now edits GitHub. +- The PR-body leg is separate from the integration-branch backlink legs (`runCloseoutBacklinkLeg`, `finalizeCleanupBacklinkRepair`, `sweepAssessBacklinkLeg`). Do not merge them, rename them, or change their behavior. +- Out of scope, unchanged: the backlink wording; adding links to the PR body; PRs of killed changes (filter on `domain.StatusDone`); private-visibility repositories; moving plans, results, or evidence. +- Docs describe current behavior only, with no change or PR numbers (`TestLivingDocsAlignment`). ADR citations are allowed. Never hand-copy under `internal/assets/embedded/`; regenerate it with `go generate ./internal/assets/`. +- Cross-references in maintained source anchor on symbol names or quoted clauses, never line numbers (`TestCommentAnchorStyle`). +- Mint every finding code from a named constant, never a `Code: "..."` literal (`TestNoInlineFindingCodeLiterals`). The new `ReasonPRBacklinkPending` follows the existing `ReasonCloseoutBacklinkPending` pattern. +- Every mutation probe and re-verification uses `go test -count=1` (learning `cached-runner-serves-a-mutated-tree`). Every mutation is restored from a backup copy (`cp f f.bak` … `mv -f f.bak f`), never `git checkout --` (learning `mutation-restore-needs-a-backup-copy`). +- Integration tests run only through their shard prefixes: `go test -tags integration -count=1 -run '^' `. Never run `./internal/app/` integration tests without `-run`. The prefixes used here already exist: `TestIntegrationMerge` (githubcli merge shard), `TestIntegrationFinalizeCloseout`, `TestIntegrationFinalizeArchive`, `TestIntegrationFinalizeCleanup`, `TestIntegrationRepoRepair`. Do not add shards. +- **Never mutate a real PR during the build.** Task 1's real `gh` call is read-only. Never run `repository repair --pr-backlinks --yes` against `danielhanold/docket`; that one-time run is the human's after merge. +- Whole-suite gate: `go run ./cmd/docket development test`. Read its budget report even when green. + +## Review Focus + +1. **A PR body with CRLF line endings** (any body edited in the GitHub web UI). A person expects the CRLF bytes outside the block to survive unchanged and only the block to change. Pinned in Task 2, `TestPlanPRBacklinkRepointPreservesCRLFProse`. +2. **A PR body whose backlink markers are malformed** (dangling start, end before start). A person expects docket to leave it alone and say so: close-out and cleanup report `pr-backlink-pending`, the sweep reports the leg as unknown, and the repair reports it unreadable and skips it. It is never edited, and nothing is inserted. Pinned in Task 2 (`TestPlanPRBacklinkRepointMalformedMarkersIsError`, `TestRepointPRBacklinkMalformedNeverEdits`), Task 5 (`TestAssessPRBacklinkMalformedIsUnknown`), and Task 6 (`TestIntegrationRepoRepairPRBacklinksSkipsUnreadable`). +3. **A human edits the PR description between docket's read and its write.** A person expects docket not to overwrite their edit: the outcome is `contended`, and the next retry reads the new body. Pinned in Task 1 (`contended-revision-drift`, `verify-shows-different-body`) and Task 6 (the contended entry in `TestIntegrationRepoRepairPRBacklinksSkipsUnreadable`). +4. **A stack root closing out with carried descendants.** A person expects each descendant's PR to link to that descendant's own archive path, not the root's. Pinned in Task 3, subtest `repoints-each-carried-pr-backlink`. +5. **A PR the batched read cannot see** (a `Found=false` slot or a failed batch). A person expects the sweep to report that record as unknown rather than certify it as no-work, and the repair to report the PR as unreadable rather than drop it silently. Pinned in Task 5 (`TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown`) and Task 6. + +## File Structure + +- `internal/githubcli/bodyedit.go` (create): `BodyEditOutcome`, `EditPullRequestBody`. +- `internal/githubcli/bodyedit_integration_test.go` (create): fake-gh probe→act→verify matrix, prefix `TestIntegrationMergeBodyEdit` (runs in the existing `tests/test_go_integration_githubcli_merge.sh` shard). +- `internal/app/pr_backlink_repoint.go` (create): `ReasonPRBacklinkPending`, `PRBodyEditor`, `FinalizePRBody`, `prBodyEditor`, `planPRBacklinkRepoint`, `repointPRBacklink`, `applyPRBacklinkRepoint`, `prBacklinkFinding`. +- `internal/app/pr_backlink_repoint_test.go` (create, untagged so integration tests can share its fake): `fakePRBody`, `prBodyWithActiveBacklink`, and the unit tests. +- `internal/app/finalize_context.go` (modify): `FinalizeDeps.PRBody`. +- `internal/app/finalize_closeout.go` (modify): `runCloseoutPRBacklinkLeg`, called from `closeoutIntegrationDestination`; file header comment. +- `internal/app/finalize_closeout_integration_test.go` (modify): new `TestIntegrationFinalizeCloseoutPRBacklink*` tests; new subtest in `TestIntegrationFinalizeArchiveRootCarry`. +- `internal/app/finalize_cleanup.go` (modify): `finalizeCleanupPRBacklinkRepair`, called from `finalizeCleanupDone`; file header comment. +- `internal/app/finalize_cleanup_integration_test.go` (modify): `TestIntegrationFinalizeCleanupPRBacklinkRetry`. +- `internal/app/sweep_prfacts.go` (modify): `SweepPRSetResult.Bodies`. +- `internal/app/maintenance.go` (modify): `gatherSweepSharedFacts` reads done candidates' PR bodies once; `MaintenanceSweep` passes `repoDir`. +- `internal/app/maintenance_assess.go` (modify): `sweepSharedFacts` PR-body fields, `sweepLegPRBacklink`, `sweepAssessPRBacklinkLeg`. +- `internal/app/maintenance_assess_test.go`, `internal/app/sweep_prfacts_test.go` (modify). +- `internal/app/repository_facts.go` (modify): `SetupDeps.GitHub`. +- `internal/app/repository_repair.go` (modify): `RepairOptions.PRBacklinks`, `repairPreflight` (extracted), result field `PRBacklinks`. +- `internal/app/repository_repair_prbacklinks.go` (create): `RepairGitHub`, `PRBacklinkRepair`, outcome constants, `runPRBacklinkRepair` and helpers. +- `internal/app/repository_repair_integration_test.go` (modify): `TestIntegrationRepoRepairPRBacklinks*`, `TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies`. +- `internal/cli/repository.go`, `internal/cli/repository_test.go` (modify): `--pr-backlinks`, `repositoryRepairGitHub`, effects. +- `internal/cli/finalize.go`, `internal/cli/finalize_test.go` (modify): `finalize.closeout` effects. +- `docs/concepts/finalize-sequencer.md`, `docs/reference/glossary.md`, `skills/docket-finalize-change/SKILL.md` (modify) and the regenerated `internal/assets/embedded/`. + +--- + +### Task 1: Confirm a merged PR's body is editable, then add `EditPullRequestBody` + +**Files:** +- Create: `internal/githubcli/bodyedit.go` +- Test: `internal/githubcli/bodyedit_integration_test.go` + +**Interfaces:** +- Consumes: `(*Client).ViewPullRequest` (`internal/githubcli/probe.go`), `(*Client).run`/`runRequest` (`client.go`), `validateRepository`, `newFailure`, `AsFailure`, `StageLaunch`, `StageValidate`, `KindInvalidInput` (`failure.go`). Test helpers `newFakeClient`, `fakeScenario`, `fakeArm`, `countArgv`, `ensPRJSON`, `mustDecodeOne`, `retRepo`, `retViewArm`, `retEditArm`, `ensHead`, `ensHeadOid`, `ensTitle` (`harness_integration_test.go`, `ensure_integration_test.go`, `retarget_integration_test.go`, `fixtures_test.go`). +- Produces: + ```go + type BodyEditOutcome string + const ( + BodyEdited BodyEditOutcome = "edited" + BodyAlready BodyEditOutcome = "already" + BodyContended BodyEditOutcome = "contended" + BodyUnknown BodyEditOutcome = "unknown" + ) + func (c *Client) EditPullRequestBody(ctx context.Context, repo Repository, number int, expectedRevision, body string) (BodyEditOutcome, PullRequest, error) + ``` + +- [ ] **Step 1: Probe GitHub for real (read-only) and record the result** + +The spec requires a real `gh` call confirming that a merged PR's body is editable. Use GitHub's own permission field. It is a read and mutates nothing: + +```bash +gh --version +gh api graphql \ + -f query='query($o:String!,$n:String!){repository(owner:$o,name:$n){pullRequest(number:250){number state viewerCanUpdate}}}' \ + -f o=danielhanold -f n=docket +``` + +Expected: a JSON document with `"state":"MERGED"` and `"viewerCanUpdate":true`. If `viewerCanUpdate` is `false`, if the call fails, or if `gh` is not authenticated, STOP and return BLOCKED with the output. The whole change depends on this fact. Copy the exact commands and their full output into this task's commit message body under a `Verification:` heading, and into your COMPLETE report under `Verification evidence`. The results file is assembled from that report. Do NOT run `gh pr edit` or any other write against a real PR. + +- [ ] **Step 2: Write the failing fake-gh tests** + +Create `internal/githubcli/bodyedit_integration_test.go`: + +```go +//go:build integration + +package githubcli + +import ( + "context" + "strings" + "testing" +) + +// EditPullRequestBody drives probe→act→verify for one merged PR's body through +// the protocol-faithful fake gh. Each case asserts the outcome AND the witness +// log (whether an edit ran, and what it carried on stdin), so a green result +// can never hide an unexercised or an extra external mutation. + +const ( + bodyOld = "\n> ↩ old\n\n\nAuthored prose.\r\n" + bodyNew = "\n> ↩ new\n\n\nAuthored prose.\r\n" +) + +func mergedPRJSON(body string) string { + return ensPRJSON(7, "MERGED", false, ensHead, ensHeadOid, "main", ensTitle, body) +} + +func editRecord(t *testing.T, log *witnessLog) (invocationRecord, int) { + t.Helper() + var found invocationRecord + n := 0 + for _, r := range log.records(t) { + if len(r.Argv) >= 2 && r.Argv[0] == "pr" && r.Argv[1] == "edit" { + found = r + n++ + } + } + return found, n +} + +func TestIntegrationMergeBodyEditProbeActVerify(t *testing.T) { + oldRev := mustDecodeOne(t, mergedPRJSON(bodyOld)).Revision + + t.Run("edited", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm(mergedPRJSON(bodyNew), 0), + }}) + out, pr, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyEdited { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyEdited) + } + if pr.Body != bodyNew { + t.Fatalf("verified body differs from the request") + } + rec, n := editRecord(t, log) + if n != 1 { + t.Fatalf("pr edit issued %d times, want 1", n) + } + if rec.Stdin != bodyNew { + t.Fatalf("edit stdin = %q, want the exact requested body", rec.Stdin) + } + joined := strings.Join(rec.Argv, "\x00") + for _, want := range []string{"7", "--repo", "acme/widget", "--body-file", "-"} { + if !strings.Contains(joined, want) { + t.Errorf("edit argv %v lacks %q", rec.Argv, want) + } + } + for _, a := range rec.Argv { + if strings.Contains(a, "Authored prose") { + t.Fatalf("body bytes leaked into argv: %v", rec.Argv) + } + } + for _, forbidden := range []string{"--base", "--title"} { + if strings.Contains(joined, forbidden) { + t.Errorf("a body edit must not carry %s: %v", forbidden, rec.Argv) + } + } + }) + + t.Run("already", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyNew), 0)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyAlready { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyAlready) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times on an already-correct body, want 0", n) + } + }) + + t.Run("contended-revision-drift", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyOld), 0)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, "sha256:stale", bodyNew) + if err != nil || out != BodyContended { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyContended) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times on revision drift, want 0", n) + } + }) + + t.Run("empty-revision-is-contended", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyOld), 0)}}) + out, _, _ := c.EditPullRequestBody(context.Background(), retRepo(), 7, "", bodyNew) + if out != BodyContended { + t.Fatalf("outcome = %q, want %q", out, BodyContended) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("an empty revision authorized %d edits, want 0", n) + } + }) + + t.Run("probe-error-unknown", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm("", 1)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times after a probe error, want 0", n) + } + }) + + t.Run("verify-shows-different-body", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm(mergedPRJSON("a human rewrote it\n"), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyContended { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyContended) + } + }) + + t.Run("nonzero-edit-exit-resolved-by-verify", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(1), + retViewArm(mergedPRJSON(bodyNew), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyEdited { + t.Fatalf("a landed edit with a lost response must verify as %q, got %q err=%v", BodyEdited, out, err) + } + }) + + t.Run("verify-error-unknown", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm("", 1), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + }) + + t.Run("invalid-number", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 0, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + if len(log.records(t)) != 0 { + t.Fatalf("an invalid request must spawn no gh process") + } + }) +} +``` + +Before relying on the `invalid-number` case, check that `newFakeClient` accepts an empty scenario and that `log.records` returns an empty slice when the log file was never written. If it does not, script one never-matched arm (`fakeArm{ArgvPrefix: []string{"never"}}`) instead. A supplied assert is unverified code (learning `plan-supplied-test-code-is-unverified`). + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationMergeBodyEdit' ./internal/githubcli/` +Expected: FAIL to compile, `c.EditPullRequestBody undefined`. + +- [ ] **Step 4: Implement `bodyedit.go`** + +Create `internal/githubcli/bodyedit.go`: + +```go +package githubcli + +// This file owns EditPullRequestBody: the probe→act→verify body edit of one +// exact pull request by number, in any state (a merged PR included). It is the +// primitive close-out, cleanup, and `repository repair --pr-backlinks` use to +// repoint a merged PR's docket-owned backlink block at the archived change +// record. It mirrors retarget.go's fixed shape: +// +// 1. probe the PR by number for its current body and opaque revision; +// 2. a body already equal to the request is `already` — no edit (idempotency +// keyed on the promised remote state, never on the revision); +// 3. an edit is authorized only when ExpectedRevision equals the live revision; +// an empty or mismatched revision is `contended` and the PR is untouched; +// 4. `gh pr edit --repo --body-file -` carries the body on stdin +// only — never in argv or a diagnostic — and edits nothing else; and +// 5. a fresh by-number probe re-derives the postcondition: an equal body is +// `edited`, a different body is `contended` (a race, reported, never rolled +// back), and a probe that cannot be established is `unknown`. +// +// An errored probe is never read as clean absence (learning +// probe-error-is-not-clean-absence): a launch/timeout/cancel Failure or a +// non-zero exit is `unknown`, and `unknown` never authorizes the edit. + +import ( + "context" + "strconv" +) + +// bodyEditOp labels every Failure raised while editing a pull-request body. +const bodyEditOp = "edit-pull-request-body" + +// BodyEditOutcome is the closed set of body-edit outcomes. +type BodyEditOutcome string + +const ( + // BodyEdited: the body was replaced and the postcondition verified. + BodyEdited BodyEditOutcome = "edited" + // BodyAlready: the PR already carried exactly the requested body; no edit. + BodyAlready BodyEditOutcome = "already" + // BodyContended: the live revision diverged from ExpectedRevision, or the + // verified body differs from the request; the PR was not overwritten. + BodyContended BodyEditOutcome = "contended" + // BodyUnknown: an external probe could not establish the truth (or the input + // was invalid); nothing was authorized. The returned error is the diagnostic. + BodyUnknown BodyEditOutcome = "unknown" +) + +// EditPullRequestBody replaces one exact PR's body, idempotently. +// edited/already/contended are value outcomes returned with a nil error; unknown +// carries a typed *Failure. The PR snapshot is populated on edited/already. +func (c *Client) EditPullRequestBody(ctx context.Context, repo Repository, number int, expectedRevision, body string) (BodyEditOutcome, PullRequest, error) { + if err := validateRepository(repo); err != nil { + return BodyUnknown, PullRequest{}, newFailure(bodyEditOp, StageValidate, KindInvalidInput, "repository identity invalid: "+err.Error(), err) + } + if number <= 0 { + return BodyUnknown, PullRequest{}, newFailure(bodyEditOp, StageValidate, KindInvalidInput, "pull request number must be positive", nil) + } + + pr, err := c.ViewPullRequest(ctx, repo, number) + if err != nil { + return BodyUnknown, PullRequest{}, err + } + if pr.Body == body { + return BodyAlready, pr, nil + } + if expectedRevision == "" || expectedRevision != pr.Revision { + return BodyContended, PullRequest{}, nil + } + + _, mf := c.run(ctx, runRequest{ + op: bodyEditOp, + args: []string{ + "pr", "edit", strconv.Itoa(number), + "--repo", repo.Spec(), + "--body-file", "-", + }, + stdin: []byte(body), + network: true, + write: true, + }) + if mf != nil && mf.Stage == StageLaunch { + // gh never started; nothing was mutated. + return BodyUnknown, PullRequest{}, mf + } + // A zero exit, a non-zero exit, a timeout, or a cancel may all have reached + // GitHub: resolve every one by the same fresh verify probe. + after, err := c.ViewPullRequest(ctx, repo, number) + if err != nil { + return BodyUnknown, PullRequest{}, err + } + if after.Body == body { + return BodyEdited, after, nil + } + return BodyContended, PullRequest{}, nil +} +``` + +Check that `StageLaunch` is the constant `retarget.go` uses for "gh never started" (`grep -n StageLaunch internal/githubcli/failure.go`). Use whatever name it actually has. + +- [ ] **Step 5: Run the tests to verify they pass, then mutation-test the revision gate** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationMergeBodyEdit' ./internal/githubcli/` +Expected: PASS. + +Mutation: `cp internal/githubcli/bodyedit.go /tmp/bodyedit.go.bak`, delete the `expectedRevision == "" || expectedRevision != pr.Revision` block, rerun. Expected: `contended-revision-drift` and `empty-revision-is-contended` FAIL. Restore with `mv -f /tmp/bodyedit.go.bak internal/githubcli/bodyedit.go` and rerun to PASS. Also run the unit boundary guard: `go test -count=1 ./internal/githubcli/`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/githubcli/bodyedit.go internal/githubcli/bodyedit_integration_test.go +git commit -m "feat(githubcli): edit a pull request's body by number under an exact revision" -m "Verification: " +``` + +--- + +### Task 2: The shared PR-backlink repoint decision and edit sequence + +**Files:** +- Create: `internal/app/pr_backlink_repoint.go` +- Create: `internal/app/pr_backlink_repoint_test.go` +- Modify: `internal/app/finalize_context.go` (`FinalizeDeps`) + +**Interfaces:** +- Consumes: `githubcli.BodyEditOutcome` and constants and `(*githubcli.Client).EditPullRequestBody` (Task 1); `document.Parse`, `Document.Block`, `Block.Interior` (`internal/document/markers.go`), `document.PatchSet.ReplaceBlock`, `Document.Apply`; `backlinkBlockName` (`artifact_backlink.go`); `StatusFinding`, `domain.SeverityWarning`. +- Produces: + ```go + const ReasonPRBacklinkPending = "pr-backlink-pending" + const ( + prBacklinkRepointed = "repointed" + prBacklinkAlready = "already" + prBacklinkNoBlock = "no-block" + prBacklinkContended = "contended" + prBacklinkUnknown = "unknown" + ) + type PRBodyEditor interface { + EditPullRequestBody(ctx context.Context, repo githubcli.Repository, number int, expectedRevision, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) + } + type FinalizePRBody interface { + ViewPullRequest(ctx context.Context, repo githubcli.Repository, number int) (githubcli.PullRequest, error) + PRBodyEditor + } + type prBacklinkResult struct{ outcome, current, detail string } + func prBodyEditor(deps FinalizeDeps) FinalizePRBody + func planPRBacklinkRepoint(body []byte, archivePath, archivedInterior string) (updated []byte, current string, present, needs bool, err error) + func repointPRBacklink(ctx context.Context, ed FinalizePRBody, repo githubcli.Repository, number int, archivePath, archivedInterior string) prBacklinkResult + func applyPRBacklinkRepoint(ctx context.Context, ed PRBodyEditor, repo githubcli.Repository, number int, body, revision, archivePath, archivedInterior string) prBacklinkResult + func prBacklinkFinding(id, number int, r prBacklinkResult) *StatusFinding + // FinalizeDeps gains: PRBody FinalizePRBody + // test helpers (pr_backlink_repoint_test.go, untagged): + type fakePRBody struct{ bodies map[int]string; viewErr, editErr error; editOut githubcli.BodyEditOutcome; views, edits int } + func newFakePRBody(bodies map[int]string) *fakePRBody + func prBodyWithActiveBacklink(activePath, authored string) string + ``` + +The decision is **path-keyed**: a block whose interior already contains the archive path is a no-op, whatever its title wording or link form (both `render.BacklinkContent` forms, the `[…]()` link and the `` `relPath` `` fallback, end in the record path). A body that needs work gets the canonical rendered archive interior. The `archivedInterior` argument is always produced by the caller with the existing renderer (`archivedBacklinkInterior` or `render.BacklinkContent` + `backlinkInterior`), so the rendering is never copied. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/app/pr_backlink_repoint_test.go`: + +```go +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/githubcli" +) + +// fakePRBody is a scriptable FinalizePRBody keyed by PR number. Its revision is +// a digest of the current body, so a body change moves the revision exactly as +// GitHub's does. Shared with the integration tests (untagged file). +type fakePRBody struct { + bodies map[int]string + viewErr error + editErr error + editOut githubcli.BodyEditOutcome // "" applies the edit; any other value is returned as-is + views int + edits int +} + +func newFakePRBody(bodies map[int]string) *fakePRBody { return &fakePRBody{bodies: bodies} } + +func (f *fakePRBody) rev(n int) string { + sum := sha256.Sum256([]byte(f.bodies[n])) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (f *fakePRBody) ViewPullRequest(_ context.Context, _ githubcli.Repository, n int) (githubcli.PullRequest, error) { + f.views++ + if f.viewErr != nil { + return githubcli.PullRequest{}, f.viewErr + } + b, ok := f.bodies[n] + if !ok { + return githubcli.PullRequest{}, errors.New("fake: no such pull request") + } + return githubcli.PullRequest{Number: n, State: githubcli.StateMerged, Body: b, Revision: f.rev(n)}, nil +} + +func (f *fakePRBody) EditPullRequestBody(_ context.Context, _ githubcli.Repository, n int, rev, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) { + f.edits++ + if f.editErr != nil { + return githubcli.BodyUnknown, githubcli.PullRequest{}, f.editErr + } + if f.editOut != "" { + return f.editOut, githubcli.PullRequest{}, nil + } + if rev != f.rev(n) { + return githubcli.BodyContended, githubcli.PullRequest{}, nil + } + f.bodies[n] = body + return githubcli.BodyEdited, githubcli.PullRequest{Number: n, Body: body, Revision: f.rev(n)}, nil +} + +// prBodyWithActiveBacklink is a PR body as pr.publish leaves it: the backlink +// block (fallback form, naming the ACTIVE record path) at the top, then +// authored prose and a build-evidence-shaped block that must survive. +func prBodyWithActiveBacklink(activePath, authored string) string { + return "\n" + + "> ↩ **Change 0005 — A change** — `" + activePath + "`\n" + + "\n\n" + + authored + "\n\n" + + "\nevidence\n\n" +} + +const ( + tActive = "docs/changes/active/0005-widget.md" + tArchive = "docs/changes/archive/2026-08-18-0005-widget.md" + tInterior = "> ↩ **Change 0005 — A change** — `" + tArchive + "`" +) + +// outsideBlock returns body with the backlink block's interior removed, so two +// bodies can be compared on every byte the repoint must not touch. +func outsideBlock(t *testing.T, body string) string { + t.Helper() + start := strings.Index(body, "-->") + end := strings.Index(body, "\n" + + "> ↩ **Change 0005 — An OLDER title** — `" + tArchive + "`\n" + + "\n\nprose\n" + _, _, present, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || !present || needs { + t.Fatalf("present=%v needs=%v err=%v, want present no-work", present, needs, err) + } +} + +func TestPlanPRBacklinkRepointNoBlockIsNeverGivenOne(t *testing.T) { + body := "A hand-written PR description with no docket block.\n" + updated, _, present, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || present || needs { + t.Fatalf("present=%v needs=%v err=%v, want absent no-work", present, needs, err) + } + if string(updated) != body { + t.Fatalf("a block-less body was rewritten") + } +} + +func TestPlanPRBacklinkRepointPreservesCRLFProse(t *testing.T) { + body := strings.ReplaceAll(prBodyWithActiveBacklink(tActive, "Line one.\nLine two."), "\n", "\r\n") + updated, _, _, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || !needs { + t.Fatalf("needs=%v err=%v, want a repoint of a CRLF body", needs, err) + } + if outsideBlock(t, string(updated)) != outsideBlock(t, body) { + t.Fatalf("CRLF bytes outside the block changed:\nbefore %q\nafter %q", body, string(updated)) + } +} + +func TestPlanPRBacklinkRepointMalformedMarkersIsError(t *testing.T) { + body := "\n> ↩ dangling\n\nprose\n" + if _, _, _, _, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior); err == nil { + t.Fatal("a dangling backlink marker must be an error (unknown), never a clean no-op") + } +} + +func TestRepointPRBacklinkEditsOnceThenIsIdempotent(t *testing.T) { + ed := newFakePRBody(map[int]string{7: prBodyWithActiveBacklink(tActive, "prose")}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkRepointed || ed.edits != 1 { + t.Fatalf("first = %+v edits=%d, want repointed once", r, ed.edits) + } + r = repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkAlready || ed.edits != 1 { + t.Fatalf("second = %+v edits=%d, want already with no new edit", r, ed.edits) + } + if prBacklinkFinding(5, 7, r) != nil { + t.Fatal("an already-correct PR must produce no finding") + } +} + +func TestRepointPRBacklinkMalformedNeverEdits(t *testing.T) { + ed := newFakePRBody(map[int]string{7: "\nbad\n"}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkUnknown || ed.edits != 0 { + t.Fatalf("malformed = %+v edits=%d, want unknown with no edit", r, ed.edits) + } + if f := prBacklinkFinding(5, 7, r); f == nil || f.Code != ReasonPRBacklinkPending { + t.Fatalf("malformed must surface a %s finding, got %+v", ReasonPRBacklinkPending, f) + } +} + +func TestRepointPRBacklinkFailuresArePendingFindings(t *testing.T) { + for name, ed := range map[string]*fakePRBody{ + "view-error": {bodies: map[int]string{}, viewErr: errors.New("gh: network down")}, + "edit-error": {bodies: map[int]string{7: prBodyWithActiveBacklink(tActive, "p")}, editErr: errors.New("gh: 502")}, + "contended": {bodies: map[int]string{7: prBodyWithActiveBacklink(tActive, "p")}, editOut: githubcli.BodyContended}, + } { + t.Run(name, func(t *testing.T) { + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + f := prBacklinkFinding(5, 7, r) + if f == nil || f.Code != ReasonPRBacklinkPending || !strings.Contains(f.Message, "#7") { + t.Fatalf("%s: finding = %+v, want a %s finding naming PR #7", name, f, ReasonPRBacklinkPending) + } + if strings.Contains(f.Message, "prose") || strings.Contains(f.Message, "Authored") { + t.Fatalf("%s: the finding leaked PR body bytes: %q", name, f.Message) + } + }) + } +} + +func TestRepointPRBacklinkNoBlockNoEditNoFinding(t *testing.T) { + ed := newFakePRBody(map[int]string{7: "hand-written\n"}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkNoBlock || ed.edits != 0 || prBacklinkFinding(5, 7, r) != nil { + t.Fatalf("no-block = %+v edits=%d, want no edit and no finding", r, ed.edits) + } +} + +func TestPRBodyEditorFallsBackToTheGitHubClient(t *testing.T) { + if prBodyEditor(FinalizeDeps{GitHub: &githubcli.Client{}}) == nil { + t.Fatal("a *githubcli.Client in deps.GitHub must serve as the PR-body editor (production wiring)") + } + if prBodyEditor(FinalizeDeps{}) != nil { + t.Fatal("no editor is wired when neither PRBody nor a capable GitHub client is present") + } + ed := newFakePRBody(nil) + if prBodyEditor(FinalizeDeps{PRBody: ed}) != FinalizePRBody(ed) { + t.Fatal("an explicit PRBody seam wins") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 -run 'TestPlanPRBacklinkRepoint|TestRepointPRBacklink|TestPRBodyEditor' ./internal/app/` +Expected: FAIL to compile, `planPRBacklinkRepoint undefined` (and the related names). + +- [ ] **Step 3: Add the `FinalizeDeps.PRBody` field** + +In `internal/app/finalize_context.go`, inside `type FinalizeDeps struct`, after `PRBatch SweepPRBatchReader`: + +```go + // PRBody is the narrow GitHub seam close-out and cleanup repoint a merged + // PR's docket:backlink block through (pr_backlink_repoint.go). Production + // leaves it nil: prBodyEditor falls back to GitHub when that client satisfies + // FinalizePRBody (the real *githubcli.Client does). A test injects a fake. + PRBody FinalizePRBody +``` + +- [ ] **Step 4: Implement `pr_backlink_repoint.go`** + +```go +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" +) + +// This file is the one place that decides whether a merged pull request's +// docket:backlink block must be repointed at the change's archived record, and +// the one edit sequence that does it. Close-out (runCloseoutPRBacklinkLeg), +// cleanup (finalizeCleanupPRBacklinkRepair), the sweep assessment +// (sweepAssessPRBacklinkLeg), and `repository repair --pr-backlinks` all call +// it; none keeps a copy of the predicate. +// +// The decision is path-keyed: a block whose interior already names the archive +// path is a no-op whatever its title wording, and a body with no block is never +// given one. Only the docket-owned block is replaced, through the loss-preserving +// document patch path, so every authored byte (CRLF endings and the build- +// evidence block included) survives. The leg is best-effort: a failure is a +// pr-backlink-pending warning, never a stop. PR body bytes never reach a finding. + +// ReasonPRBacklinkPending: a merged PR's backlink block could not be repointed at +// the archived record (a read, edit, or marker problem, or a lost race); the +// change stays truthfully done and cleanup / the sweep retry it. +const ReasonPRBacklinkPending = "pr-backlink-pending" + +// The closed outcomes of one repoint attempt. +const ( + prBacklinkRepointed = "repointed" // the block now names the archive path + prBacklinkAlready = "already" // the block already named it; no edit + prBacklinkNoBlock = "no-block" // a hand-written body; never given a block + prBacklinkContended = "contended" // the body moved under us; not overwritten + prBacklinkUnknown = "unknown" // unreadable, malformed, or unverified +) + +// PRBodyEditor is the one GitHub write the repoint needs. +type PRBodyEditor interface { + EditPullRequestBody(ctx context.Context, repo githubcli.Repository, number int, expectedRevision, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) +} + +// FinalizePRBody is the read+write seam close-out and cleanup use. +type FinalizePRBody interface { + ViewPullRequest(ctx context.Context, repo githubcli.Repository, number int) (githubcli.PullRequest, error) + PRBodyEditor +} + +// The production client satisfies the seam, so prBodyEditor's fallback is live. +var _ FinalizePRBody = (*githubcli.Client)(nil) + +// prBodyEditor returns the injected PRBody seam, else deps.GitHub when it +// satisfies FinalizePRBody (production: the real client), else nil (a test +// fake GitHub that cannot edit bodies — the leg is then not run). +func prBodyEditor(deps FinalizeDeps) FinalizePRBody { + if deps.PRBody != nil { + return deps.PRBody + } + if ed, ok := deps.GitHub.(FinalizePRBody); ok { + return ed + } + return nil +} + +// prBacklinkResult is one attempt's outcome, the block interior it found +// (generated text only), and a redacted diagnostic. +type prBacklinkResult struct { + outcome string + current string + detail string +} + +// planPRBacklinkRepoint is the pure decision over one PR body. present reports +// whether a docket:backlink block exists; needs reports whether its interior +// fails to name archivePath; updated is the body with only that block's interior +// replaced by archivedInterior (the input bytes unchanged when !needs). A parse +// or patch failure — malformed, unbalanced, or nested markers — is an error. +func planPRBacklinkRepoint(body []byte, archivePath, archivedInterior string) (updated []byte, current string, present, needs bool, err error) { + doc, err := document.Parse(body) + if err != nil { + return nil, "", false, false, err + } + blk, ok := doc.Block(backlinkBlockName) + if !ok { + return body, "", false, false, nil + } + current = strings.TrimRight(string(body[blk.Interior.Start:blk.Interior.End]), "\r\n") + if strings.Contains(current, archivePath) { + return body, current, true, false, nil + } + var ps document.PatchSet + ps.ReplaceBlock(backlinkBlockName, archivedInterior) + updated, err = doc.Apply(ps) + if err != nil { + return nil, current, true, false, err + } + return updated, current, true, true, nil +} + +// repointPRBacklink reads one PR by number and repoints its block. +func repointPRBacklink(ctx context.Context, ed FinalizePRBody, repo githubcli.Repository, number int, archivePath, archivedInterior string) prBacklinkResult { + pr, err := ed.ViewPullRequest(ctx, repo, number) + if err != nil { + return prBacklinkResult{outcome: prBacklinkUnknown, detail: "the pull-request body could not be read: " + err.Error()} + } + return applyPRBacklinkRepoint(ctx, ed, repo, number, pr.Body, pr.Revision, archivePath, archivedInterior) +} + +// applyPRBacklinkRepoint repoints a body already in hand (the repair reads it in +// a batch), editing only under the revision that body was read at. +func applyPRBacklinkRepoint(ctx context.Context, ed PRBodyEditor, repo githubcli.Repository, number int, body, revision, archivePath, archivedInterior string) prBacklinkResult { + updated, current, present, needs, err := planPRBacklinkRepoint([]byte(body), archivePath, archivedInterior) + if err != nil { + return prBacklinkResult{outcome: prBacklinkUnknown, detail: "the pull-request body carries a malformed docket:backlink block"} + } + if !present { + return prBacklinkResult{outcome: prBacklinkNoBlock} + } + if !needs { + return prBacklinkResult{outcome: prBacklinkAlready, current: current} + } + out, _, err := ed.EditPullRequestBody(ctx, repo, number, revision, string(updated)) + switch { + case err != nil: + return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit could not be verified: " + err.Error()} + case out == githubcli.BodyEdited || out == githubcli.BodyAlready: + return prBacklinkResult{outcome: prBacklinkRepointed, current: current} + case out == githubcli.BodyContended: + return prBacklinkResult{outcome: prBacklinkContended, current: current, detail: "the body changed since it was read"} + default: + return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit outcome is unknown"} + } +} + +// prBacklinkFinding maps an attempt to a retryable warning, or nil when the PR is +// in its promised state (repointed, already, or a block-less body). +func prBacklinkFinding(id, number int, r prBacklinkResult) *StatusFinding { + switch r.outcome { + case prBacklinkRepointed, prBacklinkAlready, prBacklinkNoBlock: + return nil + } + msg := fmt.Sprintf("change %04d is done, but PR #%d's change backlink was not repointed to the archived record (%s)", id, number, r.outcome) + if r.detail != "" { + msg += ": " + r.detail + } + msg += "; cleanup and the maintenance sweep will retry it" + return &StatusFinding{Code: ReasonPRBacklinkPending, Severity: string(domain.SeverityWarning), Message: msg} +} +``` + +Verify by grep that `Document.Apply` returns `([]byte, error)` (it does in `assemblePRBody` and `backlinkLegRetarget`), and that `Block.Interior` spans source bytes, so slicing `body` is correct. + +- [ ] **Step 5: Run the tests to verify they pass, then mutation-test the path key** + +Run: `go test -count=1 -run 'TestPlanPRBacklinkRepoint|TestRepointPRBacklink|TestPRBodyEditor' ./internal/app/` +Expected: PASS. + +Mutation: back up `pr_backlink_repoint.go`, change `if strings.Contains(current, archivePath)` to `if false`, rerun. Expected: `TestPlanPRBacklinkRepointAlreadyArchivedIsNoWork` and `TestRepointPRBacklinkEditsOnceThenIsIdempotent` FAIL. Restore from the backup (`mv -f`) and rerun to PASS. Then run `go test -count=1 ./internal/app/`. The package's literal-code and anchor guards must stay green. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/pr_backlink_repoint.go internal/app/pr_backlink_repoint_test.go internal/app/finalize_context.go +git commit -m "feat(app): one shared decision and edit sequence for repointing a PR's change backlink" +``` + +--- + +### Task 3: Close-out repoints every archived target's PR backlink + +**Files:** +- Modify: `internal/app/finalize_closeout.go` (`closeoutIntegrationDestination`, new `runCloseoutPRBacklinkLeg`, file header comment) +- Modify: `internal/cli/finalize.go` (`finalize.closeout` annotation) +- Test: `internal/app/finalize_closeout_integration_test.go`, `internal/cli/finalize_test.go` + +**Interfaces:** +- Consumes: `prBodyEditor`, `repointPRBacklink`, `prBacklinkFinding`, `ReasonPRBacklinkPending`, `fakePRBody`, `newFakePRBody` (Task 2); `archivedBacklinkInterior`, `closeoutTarget`, `closeoutContext` (`cc.snap`, `cc.sources`, `cc.eff`, `cc.link`), `parsePRNumber`, `finalizeHasPRRef`; fixtures `setupCloseoutFixture`, `planRepoModes`, `mergeIntoBase`, `baselineMergedFake`, `closeoutDeps`, `groomPath`, `artifactWithBacklink`, `closeoutPR` (`finalize_closeout_test.go`). +- Produces: `func runCloseoutPRBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, targets []closeoutTarget) []StatusFinding` + +- [ ] **Step 1: Write the failing integration tests** + +Append to `internal/app/finalize_closeout_integration_test.go`: + +```go +// TestIntegrationFinalizeCloseoutPRBacklinkRepointed proves close-out repoints +// the merged PR's backlink block at the archive path, keeps every authored byte, +// and is idempotent on replay. +func TestIntegrationFinalizeCloseoutPRBacklinkRepointed(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + recPath := groomPath(f.id, f.slug) + before := artifactWithBacklink(recPath, "Summary", "Authored PR prose.") + pr := newFakePRBody(map[int]string{closeoutPR: before}) + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { + t.Fatalf("closeout = %q disp %q (%s)", res.Result, res.Disposition, res.Message) + } + for _, fd := range res.Findings { + if fd.Code == ReasonPRBacklinkPending { + t.Fatalf("the PR-body leg did not land: %+v", fd) + } + } + after := pr.bodies[closeoutPR] + if !strings.Contains(after, res.ArchivePath) || strings.Contains(after, "`"+recPath+"`") { + t.Fatalf("PR body not repointed to %q:\n%s", res.ArchivePath, after) + } + if !strings.HasSuffix(after, "# Summary\n\nAuthored PR prose.\n") { + t.Fatalf("authored PR bytes changed:\n%s", after) + } + if pr.edits != 1 { + t.Fatalf("edits = %d, want 1", pr.edits) + } + + // Replay: the promised state already holds; no further edit. + again := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if again.Disposition != CloseoutDispAlready || pr.edits != 1 { + t.Fatalf("replay = disp %q edits=%d, want already with no new edit", again.Disposition, pr.edits) + } +} + +// TestIntegrationFinalizeCloseoutPRBacklinkFailureIsPendingOnly proves a failed +// GitHub edit never changes close-out's disposition: the change is done and +// archived, with one pr-backlink-pending warning. +func TestIntegrationFinalizeCloseoutPRBacklinkFailureIsPendingOnly(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(groomPath(f.id, f.slug), "Summary", "p")}) + pr.editErr = errors.New("gh: HTTP 502") + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { + t.Fatalf("a failed PR edit changed close-out: %q disp %q", res.Result, res.Disposition) + } + n := 0 + for _, fd := range res.Findings { + if fd.Code == ReasonPRBacklinkPending { + n++ + if fd.Severity != string(domain.SeverityWarning) { + t.Errorf("finding severity = %q, want warning", fd.Severity) + } + } + } + if n != 1 { + t.Fatalf("want exactly one %s finding, got %d: %+v", ReasonPRBacklinkPending, n, res.Findings) + } +} + +// TestIntegrationFinalizeCloseoutPRBacklinkNoBlockUntouched proves a +// hand-written PR body is never given a block and produces no finding. +func TestIntegrationFinalizeCloseoutPRBacklinkNoBlockUntouched(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + pr := newFakePRBody(map[int]string{closeoutPR: "Hand-written, no docket block.\n"}) + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Disposition != CloseoutDispDoneArchived || pr.edits != 0 { + t.Fatalf("disp %q edits=%d, want done-archived with no edit", res.Disposition, pr.edits) + } + if pr.bodies[closeoutPR] != "Hand-written, no docket block.\n" { + t.Fatalf("a block-less body was changed") + } +} +``` + +Add `"github.com/danielhanold/docket/internal/domain"` to that file's imports if it is not already there (`errors` is). In `TestIntegrationFinalizeArchiveRootCarry`, add a subtest after `all-proven-archives-root-and-descendants`: + +```go + t.Run("repoints-each-carried-pr-backlink", func(t *testing.T) { + f := seed(t, "stacked-merged") + childMerge := f.carryOntoRootFeature(t, map[string]string{"gadget.txt": "gadget work\n"}) + mergeCommit := f.mergeIntoBase(t) + f.fetchAllIntoInvocation(t) + pr := newFakePRBody(map[int]string{ + closeoutPR: artifactWithBacklink(groomPath(5, "widget"), "Root", "root prose"), + 8: artifactWithBacklink(groomPath(6, "gadget"), "Child", "child prose"), + }) + deps := f.closeoutDeps(rootFake(f, mergeCommit, childMerge)) + deps.PRBody = pr + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + assertBothArchived(t, f, res) + if got := pr.bodies[closeoutPR]; !strings.Contains(got, "docs/changes/archive/2026-08-18-0005-widget.md") { + t.Errorf("root PR not repointed to its own archive path:\n%s", got) + } + if got := pr.bodies[8]; !strings.Contains(got, "docs/changes/archive/2026-08-18-0006-gadget.md") || strings.Contains(got, "0005-widget") { + t.Errorf("descendant PR not repointed to ITS OWN archive path:\n%s", got) + } + }) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationFinalizeCloseoutPRBacklink' ./internal/app/` and `go test -tags integration -count=1 -run '^TestIntegrationFinalizeArchiveRootCarry' ./internal/app/` +Expected: FAIL. The bodies are unchanged and `edits = 0`, because close-out does not call the leg yet. + +- [ ] **Step 3: Implement the leg** + +In `internal/app/finalize_closeout.go`, in `closeoutIntegrationDestination`, directly after the existing `runCloseoutBacklinkLeg` call and before `return res`: + +```go + // Follow-up: repoint each archived target's merged PR backlink block at its + // archive path (a GitHub edit, best-effort). A failed edit leaves the change + // truthfully done and emits a retryable pr-backlink-pending warning. + res.Findings = append(res.Findings, runCloseoutPRBacklinkLeg(ctx, deps, cc, ghRepo, targets)...) +``` + +Add the function after `runCloseoutBacklinkLeg`: + +```go +// runCloseoutPRBacklinkLeg repoints, for every archived target (the root and +// each carried descendant), its own merged PR's docket:backlink block at its own +// archive path. It is independent of the integration-ref backlink leg and never +// changes the close-out disposition: each target that did not reach its promised +// state contributes one pr-backlink-pending warning. No editor wired (a test +// GitHub fake) runs nothing. +func runCloseoutPRBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, targets []closeoutTarget) []StatusFinding { + ed := prBodyEditor(deps) + if ed == nil { + return nil + } + var out []StatusFinding + for _, tg := range targets { + c, found := cc.snap.Change(domain.ChangeID(tg.id)) + if found != domain.LookupFound { + continue + } + number, ok := parsePRNumber(c.PR().Value) + if !finalizeHasPRRef(c) || !ok { + continue + } + src, ok := cc.sources[tg.activePath] + if !ok { + out = append(out, *prBacklinkFinding(tg.id, number, prBacklinkResult{outcome: prBacklinkUnknown, detail: "the record's source bytes were not in hand"})) + continue + } + interior, err := archivedBacklinkInterior(cc.eff, tg.archivePath, src, cc.link) + if err != nil { + out = append(out, *prBacklinkFinding(tg.id, number, prBacklinkResult{outcome: prBacklinkUnknown, detail: "the archived backlink could not be rendered"})) + continue + } + if f := prBacklinkFinding(tg.id, number, repointPRBacklink(ctx, ed, ghRepo, number, tg.archivePath, interior)); f != nil { + out = append(out, *f) + } + } + return out +} +``` + +Update the file header's "Backlinks across the one topology" paragraph by appending one sentence: "A third, GitHub-side leg repoints each archived target's merged PR description block at its archive path (runCloseoutPRBacklinkLeg); a failed edit leaves the change truthfully `done` with a pr-backlink-pending warning that cleanup and the sweep retry." + +In `internal/cli/finalize.go`, change the `finalize.closeout` annotation and its comment: + +```go + // metadata-write: archive relocation + backlink retarget are + // metadata-branch transactions. external-write: the merged PR's + // description backlink is repointed at the archived record (a GitHub + // edit). It pushes no feature ref. + Annotations: capability("finalize.closeout", EffectExternalWrite, EffectMetadataWrite), +``` + +and in `TestFinalizeCloseoutRegistered` (`internal/cli/finalize_test.go`) append: + +```go + if got := cmd.Annotations[capAnnotationEffects]; got != "external-write metadata-write" { + t.Errorf("finalize closeout effects = %q, want %q", got, "external-write metadata-write") + } +``` + +- [ ] **Step 4: Run the tests to verify they pass, then mutation-test acceptance criterion 5** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationFinalizeCloseout' ./internal/app/`, `go test -tags integration -count=1 -run '^TestIntegrationFinalizeArchive' ./internal/app/`, `go test -count=1 ./internal/app/ ./internal/cli/` +Expected: PASS. The existing close-out tests stay green because their `fakeCloseoutGitHub` does not implement `EditPullRequestBody`, so no editor is wired for them. + +Mutation (spec acceptance 5): back up `finalize_closeout.go`, delete the `res.Findings = append(res.Findings, runCloseoutPRBacklinkLeg(...)...)` line, rerun `-run '^TestIntegrationFinalizeCloseoutPRBacklink'`. Expected: `TestIntegrationFinalizeCloseoutPRBacklinkRepointed` FAIL. Restore from the backup with `mv -f` and rerun to PASS. Record the mutation and its red output in your report. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/finalize_closeout.go internal/app/finalize_closeout_integration_test.go internal/cli/finalize.go internal/cli/finalize_test.go +git commit -m "feat(finalize): close-out repoints each merged PR's change backlink at its archive path" +``` + +--- + +### Task 4: Cleanup retries the PR-backlink leg + +**Files:** +- Modify: `internal/app/finalize_cleanup.go` (`finalizeCleanupDone`, new `finalizeCleanupPRBacklinkRepair`, header comment) +- Test: `internal/app/finalize_cleanup_integration_test.go` + +**Interfaces:** +- Consumes: `prBodyEditor`, `repointPRBacklink`, `prBacklinkFinding`, `ReasonPRBacklinkPending`, `newFakePRBody` (Task 2); `archivedBacklinkInterior`, `cleanupWarning`; fixtures `setupCloseoutFixture`, `planRepoModeDocket`, `archiveClosed`, `mergedCleanupFake`, `cleanupDeps` (`finalize_cleanup_test.go`). +- Produces: `func finalizeCleanupPRBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, number int) *StatusFinding` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/app/finalize_cleanup_integration_test.go`: + +```go +// TestIntegrationFinalizeCleanupPRBacklinkRetry proves cleanup is the retry for +// a PR backlink close-out left pending: a done change whose PR still names the +// active path is repointed; a failing edit makes the cleanup pending (retryable) +// without blocking the independent legs; a replay over a repointed PR issues no +// edit. +func TestIntegrationFinalizeCleanupPRBacklinkRetry(t *testing.T) { + requireRealGit(t) + + t.Run("repoints-a-pending-pr", func(t *testing.T) { + f := setupCloseoutFixture(t, planRepoModeDocket()) + head, mergeCommit := f.archiveClosed(t) + deps := f.cleanupDeps(f.mergedCleanupFake(head, mergeCommit), f.deps.Client, f.svc) + recPath := groomPath(f.id, f.slug) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(recPath, "Summary", "prose")}) + deps.PRBody = pr + + res := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if res.Disposition != CleanupDispCleaned { + t.Fatalf("cleanup = %q disp %q (%s) findings=%+v", res.Result, res.Disposition, res.Message, res.Findings) + } + if got := pr.bodies[closeoutPR]; strings.Contains(got, "`"+recPath+"`") || !strings.Contains(got, "docs/changes/archive/") { + t.Fatalf("cleanup did not repoint the PR backlink:\n%s", got) + } + replay := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if replay.Disposition == CleanupDispPending || pr.edits != 1 { + t.Fatalf("replay disp %q edits=%d, want clean with no new edit", replay.Disposition, pr.edits) + } + }) + + t.Run("failing-edit-is-pending-and-retryable", func(t *testing.T) { + f := setupCloseoutFixture(t, planRepoModeDocket()) + head, mergeCommit := f.archiveClosed(t) + deps := f.cleanupDeps(f.mergedCleanupFake(head, mergeCommit), f.deps.Client, f.svc) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(groomPath(f.id, f.slug), "Summary", "prose")}) + pr.editErr = errors.New("gh: HTTP 502") + deps.PRBody = pr + + res := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if res.Disposition != CleanupDispPending || res.Reason != ReasonPRBacklinkPending { + t.Fatalf("cleanup = disp %q reason %q, want pending/%s", res.Disposition, res.Reason, ReasonPRBacklinkPending) + } + if f.remoteBranchPresent(t) { + t.Fatalf("a PR-body failure must not block the independent ref legs") + } + }) +} +``` + +Before relying on these, check three things in the existing helpers. First, that `archiveClosed` leaves the record `done` at its archive path while `cc.change.Path()` is that archive path. Second, that `remoteBranchPresent` reports the feature ref after a normal cleanup. Third, that `"errors"` and `"strings"` are imported in this file. Adjust the asserts to the helpers' real behavior; do not change the helpers. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationFinalizeCleanupPRBacklink' ./internal/app/` +Expected: FAIL. The body is unchanged in the first subtest, and the disposition is `cleaned` instead of `pending` in the second. + +- [ ] **Step 3: Implement the leg** + +In `finalizeCleanupDone` (`internal/app/finalize_cleanup.go`), directly after the Leg 1 block (`finalizeCleanupBacklinkRepair`): + +```go + // Leg 1b: repoint the merged PR's description backlink at the archived record + // when close-out left it pending. Best-effort and independent: a failure is a + // retryable pr-backlink-pending finding and never blocks the other legs. + if f := finalizeCleanupPRBacklinkRepair(ctx, deps, cc, ghRepo, number); f != nil { + findings = append(findings, *f) + } +``` + +Add after `finalizeCleanupBacklinkRepair`: + +```go +// finalizeCleanupPRBacklinkRepair re-runs the PR-body repoint idempotently for a +// done, archived change. In the normal flow (close-out already repointed it) it +// reads the PR and issues no edit; when close-out left the leg pending it lands +// the block-only edit. No editor wired runs nothing. +func finalizeCleanupPRBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, number int) *StatusFinding { + ed := prBodyEditor(deps) + if ed == nil { + return nil + } + id := int(cc.change.ID()) + interior, err := archivedBacklinkInterior(cc.eff, cc.change.Path(), cc.body, cc.link) + if err != nil { + f := cleanupWarning(ReasonPRBacklinkPending, "the archived backlink could not be rendered; retry cleanup") + return &f + } + return prBacklinkFinding(id, number, repointPRBacklink(ctx, ed, ghRepo, number, cc.change.Path(), interior)) +} +``` + +In the file header, change "repairs the final backlinks first when needed;" to "repairs the final backlinks (and the merged PR's description backlink) first when needed;". + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationFinalizeCleanup' ./internal/app/` and `go test -count=1 ./internal/app/` +Expected: PASS. The existing cleanup tests stay green because `fakeCleanupGitHub` cannot edit bodies, so no editor is wired. + +Mutation: back up `finalize_cleanup.go`, delete the Leg 1b block, rerun `-run '^TestIntegrationFinalizeCleanupPRBacklink'`. Expected: FAIL. Restore with `mv -f` and rerun to PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/finalize_cleanup.go internal/app/finalize_cleanup_integration_test.go +git commit -m "feat(finalize): cleanup retries a pending PR backlink repoint" +``` + +--- + +### Task 5: The maintenance sweep assesses the PR-backlink leg + +**Files:** +- Modify: `internal/app/sweep_prfacts.go` (`SweepPRSetResult.Bodies`, `ProbePRSet`) +- Modify: `internal/app/maintenance.go` (`gatherSweepSharedFacts`, `MaintenanceSweep`'s `assessHistorical` closure) +- Modify: `internal/app/maintenance_assess.go` (`sweepSharedFacts`, `sweepLegPRBacklink`, `sweepAssessPRBacklinkLeg`, the call in `sweepAssessHistorical`, comments) +- Test: `internal/app/maintenance_assess_test.go`, `internal/app/sweep_prfacts_test.go` + +**Interfaces:** +- Consumes: `planPRBacklinkRepoint` (Task 2); `render.BacklinkContent`, `backlinkInterior`, `parsePRNumber`; `SweepPRBatchReader`, `fakeSweepBatchReader`, `countingSweepGitHub`, `sweepTestRepo` (`sweep_prfacts_test.go`); assessment fixtures `newAssessFixture`, `assessDoneBlob`, `assess`, `assessInterior`, `cleanWS`, `findEntry`, `actionableHas`, `assertEntry`, `prRefFor` (`maintenance_assess_test.go` and its neighbors). +- Produces: + ```go + // SweepPRSetResult gains: Bodies map[int]string // Found slots only; never logged + // sweepSharedFacts gains: prBodiesGathered bool; prBodies map[int]string; prBodiesUnknown map[int]bool + const sweepLegPRBacklink = "pr-backlink" + func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir string, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts + func sweepAssessPRBacklinkLeg(shared sweepSharedFacts, c domain.Change, link render.LinkContext, a *sweepLegAssessment) + ``` + +The leg is assessed only when `prBodiesGathered` is true. Production always sets it whenever there is a done candidate. An orchestration test that builds `sweepSharedFacts{}` by hand keeps its existing behavior. Once bodies are gathered, a PR number missing from `prBodies` is unknown, never a clean no-op. + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/app/maintenance_assess_test.go`: + +```go +// prShared builds shared facts whose PR-body inventory was gathered, carrying +// the given bodies (and nothing unknown). +func prShared(bodies map[int]string) sweepSharedFacts { + return sweepSharedFacts{ + remoteHeads: map[gitcli.RefName]gitcli.ObjectID{}, + prBodiesGathered: true, + prBodies: bodies, + prBodiesUnknown: map[int]bool{}, + } +} + +func prNumberOf(t *testing.T, f assessFixture, id int) int { + t.Helper() + c, _ := f.inv.snap.Change(domain.ChangeID(id)) + n, ok := parsePRNumber(c.PR().Value) + if !ok { + t.Fatalf("record %d carries no parsable PR", id) + } + return n +} + +// TestAssessStalePRBacklinkIsActionable: a done record whose PR body still names +// the active path has work — it is dispatched to cleanup, which repoints it. +func TestAssessStalePRBacklinkIsActionable(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", "")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{n: prBodyWithActiveBacklink("docs/changes/active/0041-archived.md", "prose")}) + _, actionable := f.assess(t, cleanWS(), shared, 41) + if !actionableHas(actionable, 41) { + t.Fatalf("a stale PR backlink must make the record actionable; actionable=%v", actionable) + } +} + +// TestAssessCorrectPRBacklinkIsNoWork: a PR body already naming the archive path +// adds no work. +func TestAssessCorrectPRBacklinkIsNoWork(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", "")}, nil) + n := prNumberOf(t, f, 41) + body := "\n" + assessInterior(t, f, 41) + "\n\n\nprose\n" + entries, actionable := f.assess(t, cleanWS(), prShared(map[int]string{n: body}), 41) + if len(actionable) != 0 { + t.Fatalf("an already-correct PR backlink must add no work; actionable=%v", actionable) + } + assertEntry(t, findEntry(entries, 41), SweepDispSkipped, ReasonSweepSnapshotNoWork) +} + +// TestAssessUnreadPRBodyIsUnknownNeverNoWork: a gathered inventory missing this +// PR (a failed batch or a Found=false slot) is unknown, never certified no-work. +func TestAssessUnreadPRBodyIsUnknownNeverNoWork(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", "")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{}) + shared.prBodiesUnknown[n] = true + entries, actionable := f.assess(t, cleanWS(), shared, 41) + if len(actionable) != 0 { + t.Fatalf("an unread PR body must not dispatch; actionable=%v", actionable) + } + e := findEntry(entries, 41) + assertEntry(t, e, SweepDispUnknown, ReasonSweepSnapshotUnknown) + if !strings.Contains(e.Message, sweepLegPRBacklink) { + t.Fatalf("the unknown leg must be named; msg=%q", e.Message) + } +} + +// TestAssessPRBacklinkMalformedIsUnknown: malformed backlink markers in the PR +// body are unknown, never no-work and never work. +func TestAssessPRBacklinkMalformedIsUnknown(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", "")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{n: "\ndangling\n"}) + entries, actionable := f.assess(t, cleanWS(), shared, 41) + if len(actionable) != 0 { + t.Fatalf("malformed markers must not dispatch; actionable=%v", actionable) + } + assertEntry(t, findEntry(entries, 41), SweepDispUnknown, ReasonSweepSnapshotUnknown) +} +``` + +Append to `internal/app/sweep_prfacts_test.go`: + +```go +// TestSweepPRSetCarriesBodiesForFoundSlotsOnly: ProbePRSet returns each Found +// slot's body and nothing for an unresolved slot. +func TestSweepPRSetCarriesBodiesForFoundSlotsOnly(t *testing.T) { + gh := &countingSweepGitHub{repo: sweepTestRepo()} + r := &sweepPRBatchReader{gh: gh} + res := r.ProbePRSet(context.Background(), "repo", []int{1, 2}) + for n, f := range res.Facts { + if _, ok := res.Bodies[n]; !ok { + t.Errorf("found PR #%d (%+v) carries no body", n, f) + } + } + for n := range res.Bodies { + if _, ok := res.Facts[n]; !ok { + t.Errorf("PR #%d has a body but no facts (an unresolved slot must carry neither)", n) + } + } +} +``` + +Read `countingSweepGitHub`'s `ViewPullRequestsBatch` first. If it answers every slot `Found: false` (the excerpt above does), give it a field that scripts some `Found: true` slots with a `PR.Body`, so the test can actually tell the two cases apart. A test where every slot is unresolved passes vacuously (learning `green-suite-untested-branch`). + +Append a gatherer test to `internal/app/maintenance_assess_test.go`: + +```go +// TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown: every done candidate's PR +// number lands in exactly one of prBodies or prBodiesUnknown, so no done record +// is silently unassessed; one batched read serves them all. +func TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "a", ""), assessDoneBlob(42, "b", "")}, nil) + n41, n42 := prNumberOf(t, f, 41), prNumberOf(t, f, 42) + batch := &fakeSweepBatchReader{result: SweepPRSetResult{ + Facts: map[int]domain.PRFacts{n41: {Number: strconv.Itoa(n41), State: "merged"}}, + Bodies: map[int]string{n41: "body"}, + }} + deps := FinalizeDeps{Planning: PlanningDeps{Reader: f.reader, Clock: testClock()}, PRBatch: batch} + items := []sweepWorkItem{{id: 41, kind: sweepKindCleanup}, {id: 42, kind: sweepKindCleanup}} + shared := gatherSweepSharedFacts(context.Background(), deps, "repo", f.inv, items) + if !shared.prBodiesGathered || batch.calls != 1 { + t.Fatalf("gathered=%v calls=%d, want one batched read", shared.prBodiesGathered, batch.calls) + } + if shared.prBodies[n41] != "body" || shared.prBodiesUnknown[n41] { + t.Errorf("PR #%d should be read", n41) + } + if _, ok := shared.prBodies[n42]; ok || !shared.prBodiesUnknown[n42] { + t.Errorf("PR #%d was not returned, so it must be unknown", n42) + } +} +``` + +Add `"strconv"` to the imports if needed. Check what `gatherSweepSharedFacts` does with a nil `Planning.Client`. The PR-body read must happen **before** that early return (Step 3), or this test sees nothing. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 -run 'TestAssess|TestGatherSweepSharedFacts|TestSweepPRSet' ./internal/app/` +Expected: FAIL to compile (`prBodiesGathered`, `Bodies`, `sweepLegPRBacklink` undefined, and the new `gatherSweepSharedFacts` arity). + +- [ ] **Step 3: Implement** + +`internal/app/sweep_prfacts.go`: add a `Bodies` field to `SweepPRSetResult`: + +```go + // Bodies carries each Found slot's PR body, for the sweep's PR-backlink leg. + // It is never logged or reported. A number absent here is unread (a failed + // batch or an unresolved slot). + Bodies map[int]string +``` + +In `ProbePRSet`, create `bodies := make(map[int]string)`, set `bodies[n] = br.PR.Body` next to `facts[n] = sweepBatchResultToFacts(n, br)`, and return `Bodies: bodies` on every return path, including the empty-request and resolution-failure paths. + +`internal/app/maintenance_assess.go`: add to `sweepSharedFacts`: + +```go + // prBodiesGathered is set when the invocation read its done candidates' PR + // bodies (one batched pass). When set, a number in neither prBodies nor + // prBodiesUnknown is still unknown — the leg never reads absence as no-work. + prBodiesGathered bool + prBodies map[int]string // PR number -> body (never logged) + prBodiesUnknown map[int]bool // PR numbers the batched read could not resolve +``` + +Add the leg name `sweepLegPRBacklink = "pr-backlink"` to the leg-name const block. In `sweepAssessHistorical`, after `sweepAssessBacklinkLeg(ctx, deps, pin, c, link, &a)`, add `sweepAssessPRBacklinkLeg(shared, c, link, &a)`. Add the function after `sweepBacklinkArtifactPaths`: + +```go +// sweepAssessPRBacklinkLeg resolves the PR-backlink leg from the shared, batched +// PR-body inventory: a body whose docket:backlink block does not name the +// archived record path has work (cleanup repoints it). A body already naming it, +// or with no block, is no-effect. An unread body or malformed markers is +// unresolved, never a clean no-op. Not gathered (an orchestration seam) is not +// assessed. +func sweepAssessPRBacklinkLeg(shared sweepSharedFacts, c domain.Change, link render.LinkContext, a *sweepLegAssessment) { + if !shared.prBodiesGathered { + return + } + n, ok := parsePRNumber(c.PR().Value) + if !ok { + return // identity already validated by the caller + } + body, read := shared.prBodies[n] + if !read || shared.prBodiesUnknown[n] { + a.markUnknown(sweepLegPRBacklink, fmt.Sprintf("PR #%d's body could not be read in the shared batch", n)) + return + } + block, err := render.BacklinkContent(c, link) + if err != nil { + a.markUnknown(sweepLegPRBacklink, "the record's archived backlink could not be rendered") + return + } + _, _, _, needs, err := planPRBacklinkRepoint([]byte(body), c.Path(), backlinkInterior(block)) + if err != nil { + a.markUnknown(sweepLegPRBacklink, fmt.Sprintf("PR #%d carries a malformed docket:backlink block", n)) + return + } + if needs { + a.markWork() + } +} +``` + +Add `"fmt"` to the imports. Update `ReasonSweepSnapshotNoWork`'s comment to read "…absent local/remote refs, and already-correct final and PR backlinks." Update the `sweepAssessHistorical` doc sentence "It dispatches no metadata, PR, or remote-ref read of its own: the remote/worktree inventories arrive in shared" to say "the remote/worktree inventories and the batched PR-body inventory arrive in shared". + +`internal/app/maintenance.go`: change the signature to `gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir string, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts`. Update its one caller in the `MaintenanceSweep` `assessHistorical` closure to pass `repoDir`. Inside, after the `if !needsShared { return sweepSharedFacts{} }` early return and **before** the git-client check, build the PR-body inventory into a local `shared`: + +```go + var shared sweepSharedFacts + // One batched PR-body read over every done candidate's PR (≤25 per process) + // for the PR-backlink leg. A number the read could not resolve is unknown. + var numbers []int + for _, it := range historical { + c, out := inv.snap.Change(domain.ChangeID(it.id)) + if out != domain.LookupFound || c.Status() != domain.StatusDone { + continue + } + if n, ok := parsePRNumber(c.PR().Value); ok { + numbers = append(numbers, n) + } + } + shared.prBodiesGathered = true + shared.prBodies = map[int]string{} + shared.prBodiesUnknown = map[int]bool{} + if len(numbers) > 0 { + var bodies map[int]string + if deps.PRBatch != nil { + bodies = deps.PRBatch.ProbePRSet(ctx, repoDir, numbers).Bodies + } + for _, n := range numbers { + if b, ok := bodies[n]; ok { + shared.prBodies[n] = b + } else { + shared.prBodiesUnknown[n] = true + } + } + } +``` + +Then make the existing missing-client return carry these fields (`shared.remoteHeadsErr, shared.worktreesErr = err, err; return shared`) and have the rest of the function fill `shared` as it does today. Update the doc comment: "…one complete remote-heads advertisement, one worktree list, and one batched read of the done candidates' PR bodies." + +- [ ] **Step 4: Run the tests to verify they pass, then mutation-test the unknown branch** + +Run: `go test -count=1 ./internal/app/` +Expected: PASS. If an existing test counts `ProbePRSet` calls across a whole full-scope sweep and now sees one more, fix the expectation only when the extra call is this designed read, and say so in your report. + +Mutation: back up `maintenance_assess.go`, change `if !read || shared.prBodiesUnknown[n]` to `if false`, rerun `-run 'TestAssessUnreadPRBodyIsUnknownNeverNoWork'`. Expected: FAIL (the record certifies as no-work). Restore with `mv -f` and rerun to PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/sweep_prfacts.go internal/app/sweep_prfacts_test.go internal/app/maintenance.go internal/app/maintenance_assess.go internal/app/maintenance_assess_test.go +git commit -m "feat(maintenance): the full sweep assesses each done record's PR backlink from one batched read" +``` + +--- + +### Task 6: `docket repository repair --pr-backlinks` + +**Files:** +- Modify: `internal/app/repository_facts.go` (`SetupDeps.GitHub`) +- Modify: `internal/app/repository_repair.go` (`RepairOptions.PRBacklinks`, `RepositoryRepairResult.PRBacklinks`, extract `repairPreflight`, dispatch) +- Create: `internal/app/repository_repair_prbacklinks.go` +- Modify: `internal/cli/repository.go`, `internal/cli/repository_test.go` +- Test: `internal/app/repository_repair_integration_test.go` + +**Interfaces:** +- Consumes: `PRBodyEditor`, `applyPRBacklinkRepoint`, `planPRBacklinkRepoint`, prBacklink outcome constants (Task 2); `githubcli.BatchPRResult`, `githubcli.BodyEditOutcome` (Task 1); `readCheckCorpus`, `buildCorpusSnapshot`, `checkCorpus.link`, `render.BacklinkContent`, `backlinkInterior`, `parsePRNumber`, `finalizeHasPRRef`, `sweepDedupeSortAsc`, `sweepChunkInts`, `sweepPRBatchCap`, `migrateSourceMoved`, `newRepairResult`, `repairExternalFailure`, `repairInternalFailure`, `repairContended`, `repairConfirmationRequiredState`; fixtures `newHealthyRepo`, `writeRepoFile`, `runGit`, `newGitClient`. +- Produces: + ```go + // SetupDeps gains: GitHub RepairGitHub (nil on every command but `repair --pr-backlinks`) + type RepairGitHub interface { + DiscoverRepository(ctx context.Context, dir string) (githubcli.Repository, error) + ViewPullRequestsBatch(ctx context.Context, repo githubcli.Repository, numbers []int) (map[int]githubcli.BatchPRResult, error) + PRBodyEditor + } + // RepairOptions gains: PRBacklinks bool + // RepositoryRepairResult gains: PRBacklinks []PRBacklinkRepair `json:"pr_backlinks,omitempty"` + type PRBacklinkRepair struct { + ID int `json:"id"` + PR int `json:"pr"` + Current string `json:"current,omitempty"` + Corrected string `json:"corrected,omitempty"` + Outcome string `json:"outcome"` + Message string `json:"message,omitempty"` + } + const ( + PRBacklinkRepairPlanned = "planned" + PRBacklinkRepairRepointed = "repointed" + PRBacklinkRepairContended = "contended" + PRBacklinkRepairUnreadable = "unreadable" + PRBacklinkRepairFailed = "failed" + ) + func runPRBacklinkRepair(ctx context.Context, d SetupDeps, o RepairOptions) RepositoryRepairResult + func repairPreflight(ctx context.Context, d SetupDeps) (setupContext, string, *RepositoryRepairResult) + // cli: var repositoryRepairGitHub = func() (app.RepairGitHub, error) + ``` + +`--pr-backlinks` selects the PR-backlink repair **only**. It does not also run the frontmatter or derived-view repairs; run plain `repository repair` for those. The candidate set is every `done` change in the pinned corpus that carries a PR reference. A candidate is planned when `planPRBacklinkRepoint` says its PR body needs work. A done record lives only at its archive path, so a block that does not name that path names a path that no longer exists, which is the spec's "dead" link. + +- [ ] **Step 1: Write the failing integration tests** + +Append to `internal/app/repository_repair_integration_test.go`: + +```go +// fakeRepairGitHub is a scriptable RepairGitHub: bodies by PR number, a batch +// failure switch, per-number edit outcomes, and call counters. +type fakeRepairGitHub struct { + bodies map[int]string + failBatch bool + editOut map[int]githubcli.BodyEditOutcome + batchCalls int + edits int + t *testing.T + forbidAll bool // any call fails the test (routine-commands guard) +} + +func (f *fakeRepairGitHub) rev(n int) string { return "rev:" + strconv.Itoa(len(f.bodies[n])) + ":" + f.bodies[n][:min(8, len(f.bodies[n]))] } + +func (f *fakeRepairGitHub) DiscoverRepository(context.Context, string) (githubcli.Repository, error) { + if f.forbidAll { + f.t.Errorf("DiscoverRepository called by a command that must not touch GitHub") + return githubcli.Repository{}, errors.New("forbidden") + } + return githubcli.Repository{Host: "github.com", Owner: "acme", Name: "widget"}, nil +} + +func (f *fakeRepairGitHub) ViewPullRequestsBatch(_ context.Context, _ githubcli.Repository, numbers []int) (map[int]githubcli.BatchPRResult, error) { + f.batchCalls++ + if f.forbidAll { + f.t.Errorf("ViewPullRequestsBatch called by a command that must not read PR bodies") + return nil, errors.New("forbidden") + } + if f.failBatch { + return nil, errors.New("gh api graphql failed") + } + out := map[int]githubcli.BatchPRResult{} + for _, n := range numbers { + b, ok := f.bodies[n] + if !ok { + out[n] = githubcli.BatchPRResult{Found: false} + continue + } + out[n] = githubcli.BatchPRResult{Found: true, PR: githubcli.PullRequest{Number: n, State: githubcli.StateMerged, Body: b, Revision: f.rev(n)}} + } + return out, nil +} + +func (f *fakeRepairGitHub) EditPullRequestBody(_ context.Context, _ githubcli.Repository, n int, rev, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) { + f.edits++ + if f.forbidAll { + f.t.Errorf("EditPullRequestBody called by a command that must not touch GitHub") + return githubcli.BodyUnknown, githubcli.PullRequest{}, errors.New("forbidden") + } + if o, ok := f.editOut[n]; ok { + return o, githubcli.PullRequest{}, nil + } + if rev != f.rev(n) { + return githubcli.BodyContended, githubcli.PullRequest{}, nil + } + f.bodies[n] = body + return githubcli.BodyEdited, githubcli.PullRequest{Number: n, Body: body}, nil +} + +const ( + prRepairPath = "docs/changes/archive/2026-08-29-0363-old-change.md" + prRepairActive = "docs/changes/active/0363-old-change.md" + prRepairOKPath = "docs/changes/archive/2026-08-30-0364-fixed-change.md" +) + +// prArchivedRecord is a minimal valid archived done record carrying a PR URL. +func prArchivedRecord(id int, slug string, pr int) string { + return "---\nid: " + strconv.Itoa(id) + "\nslug: " + slug + "\nstatus: done\ntitle: Change " + slug + + "\ntype: feature\npr: 'https://github.com/acme/widget/pull/" + strconv.Itoa(pr) + "'\n---\n\nBody for " + slug + ".\n" +} + +func prBodyNaming(path string) string { + return "\n" + + "> ↩ **Change — x** — `" + path + "`\n\n\nAuthored PR prose.\n" +} + +// publishPRBacklinkRecords publishes one archived record whose PR still names +// the active path (dead) and one whose PR already names its archive path. +func (r *initRepo) publishPRBacklinkRecords(t *testing.T) { + t.Helper() + dotDocket := filepath.Join(r.invocation, ".docket") + writeRepoFile(t, dotDocket, prRepairPath, prArchivedRecord(363, "old-change", 250)) + writeRepoFile(t, dotDocket, prRepairOKPath, prArchivedRecord(364, "fixed-change", 251)) + runGit(t, dotDocket, "add", "--", prRepairPath, prRepairOKPath) + runGit(t, dotDocket, "commit", "-q", "-m", "publish archived PR-bearing records") + runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) +} + +func (r *initRepo) runPRRepair(t *testing.T, gh RepairGitHub, o RepairOptions) RepositoryRepairResult { + t.Helper() + o.PRBacklinks = true + return RunRepositoryRepair(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation, GitHub: gh}, o) +} + +// TestIntegrationRepoRepairPRBacklinksPreviewApplyThenNothing covers spec +// acceptance 3: the preview lists exactly the dead-link PR, --yes fixes it, and +// a second run reports nothing to do. +func TestIntegrationRepoRepairPRBacklinksPreviewApplyThenNothing(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + gh := &fakeRepairGitHub{t: t, bodies: map[int]string{250: prBodyNaming(prRepairActive), 251: prBodyNaming(prRepairOKPath)}} + + preview := r.runPRRepair(t, gh, RepairOptions{}) + if !preview.ConfirmationRequired() || len(preview.PRBacklinks) != 1 { + t.Fatalf("preview = %q state %q entries %+v, want one planned PR", preview.Result, preview.RepositoryState, preview.PRBacklinks) + } + e := preview.PRBacklinks[0] + if e.ID != 363 || e.PR != 250 || e.Outcome != PRBacklinkRepairPlanned || + !strings.Contains(e.Current, prRepairActive) || !strings.Contains(e.Corrected, prRepairPath) { + t.Fatalf("preview entry = %+v", e) + } + if gh.edits != 0 { + t.Fatalf("a preview must edit nothing; edits=%d", gh.edits) + } + + applied := r.runPRRepair(t, gh, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + if applied.Result != ResultApplied || len(applied.PRBacklinks) != 1 || applied.PRBacklinks[0].Outcome != PRBacklinkRepairRepointed { + t.Fatalf("apply = %q %+v", applied.Result, applied.PRBacklinks) + } + if !strings.Contains(gh.bodies[250], prRepairPath) || !strings.HasSuffix(gh.bodies[250], "\n\nAuthored PR prose.\n") { + t.Fatalf("PR #250 not repointed with prose intact:\n%s", gh.bodies[250]) + } + if gh.bodies[251] != prBodyNaming(prRepairOKPath) { + t.Fatalf("an already-correct PR was edited") + } + + again := r.runPRRepair(t, gh, RepairOptions{}) + if again.Result != ResultNoOp || len(again.PRBacklinks) != 0 { + t.Fatalf("second run = %q %+v, want nothing to do", again.Result, again.PRBacklinks) + } +} + +// TestIntegrationRepoRepairPRBacklinksSkipsUnreadable: an unreadable PR (a +// Found=false slot) and a malformed block are reported and skipped; a contended +// edit is reported; none aborts the batch. +func TestIntegrationRepoRepairPRBacklinksSkipsUnreadable(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + dotDocket := filepath.Join(r.invocation, ".docket") + third := "docs/changes/archive/2026-08-31-0365-third-change.md" + writeRepoFile(t, dotDocket, third, prArchivedRecord(365, "third-change", 252)) + runGit(t, dotDocket, "add", "--", third) + runGit(t, dotDocket, "commit", "-q", "-m", "third") + runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + + gh := &fakeRepairGitHub{t: t, bodies: map[int]string{ + 250: prBodyNaming(prRepairActive), + 252: "\ndangling\n", + }} // 251 absent: Found=false + preview := r.runPRRepair(t, gh, RepairOptions{}) + byPR := map[int]PRBacklinkRepair{} + for _, e := range preview.PRBacklinks { + byPR[e.PR] = e + } + if byPR[250].Outcome != PRBacklinkRepairPlanned || byPR[251].Outcome != PRBacklinkRepairUnreadable || byPR[252].Outcome != PRBacklinkRepairUnreadable { + t.Fatalf("entries = %+v", preview.PRBacklinks) + } + + gh.editOut = map[int]githubcli.BodyEditOutcome{250: githubcli.BodyContended} + applied := r.runPRRepair(t, gh, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + for _, e := range applied.PRBacklinks { + if e.PR == 250 && e.Outcome != PRBacklinkRepairContended { + t.Fatalf("PR #250 outcome = %q, want contended", e.Outcome) + } + } + if gh.edits != 1 { + t.Fatalf("only the planned PR may be edited; edits=%d", gh.edits) + } +} + +// TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies pins spec acceptance 4: +// with a GitHub fake that fails on ANY call wired into SetupDeps, routine +// `repository repair` (preview and --yes), `check`, and `prepare` never call it. +func TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + r.publishHealthyDrift(t) + gh := &fakeRepairGitHub{t: t, forbidAll: true} + d := SetupDeps{Git: newGitClient(t), RepoDir: r.invocation, GitHub: gh} + + _ = RunRepositoryCheck(context.Background(), d) + _ = RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) + preview := RunRepositoryRepair(context.Background(), d, RepairOptions{}) + _ = RunRepositoryRepair(context.Background(), d, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + if gh.batchCalls != 0 || gh.edits != 0 { + t.Fatalf("routine commands touched GitHub: batch=%d edits=%d", gh.batchCalls, gh.edits) + } +} +``` + +Add imports `errors` and `github.com/danielhanold/docket/internal/githubcli` (go 1.21+ provides the `min` builtin). Check `PrepareOptions`' zero value against `RunRepositoryPrepare`'s signature, and check that a done record with only `pr:` (no `branch:`) builds into the snapshot. If the snapshot builder rejects it, add the fields the builder requires to `prArchivedRecord`, using `repairArchivedRecord` and the validator as references. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoRepairPRBacklinks|^TestIntegrationRepoRepairRoutine' ./internal/app/` +Expected: FAIL to compile (`SetupDeps.GitHub`, `RepairOptions.PRBacklinks`, `PRBacklinkRepair` undefined). + +- [ ] **Step 3: Implement the app side** + +`internal/app/repository_facts.go`, in `SetupDeps`: + +```go + // GitHub is set ONLY by `repository repair --pr-backlinks` (the one + // repository command that reads and edits merged PR bodies). Every other + // repository command leaves it nil and never consults it. + GitHub RepairGitHub +``` + +`internal/app/repository_repair.go`: add `PRBacklinks bool` to `RepairOptions` (comment: "selects the PR-backlink repair instead of the metadata repairs; requires SetupDeps.GitHub"). Add `PRBacklinks []PRBacklinkRepair \`json:"pr_backlinks,omitempty"\`` to `RepositoryRepairResult`. Move the gather + `migrateRoute` + phase switch + `metadataTip` check at the top of `RunRepositoryRepair`, unchanged, into: + +```go +// repairPreflight runs the shared gather and topology routing every repair mode +// starts with and returns the setup context and the pinned metadata tip, or the +// refusal to return verbatim. +func repairPreflight(ctx context.Context, d SetupDeps) (setupContext, string, *RepositoryRepairResult) +``` + +`RunRepositoryRepair` then becomes: + +```go +func RunRepositoryRepair(ctx context.Context, d SetupDeps, o RepairOptions) RepositoryRepairResult { + if o.PRBacklinks { + return runPRBacklinkRepair(ctx, d, o) + } + sc, metadataTip, refusal := repairPreflight(ctx, d) + if refusal != nil { + return *refusal + } + // … the existing corpus read / plan / preview / execute, unchanged … +} +``` + +Create `internal/app/repository_repair_prbacklinks.go`: + +```go +package app + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/render" + "github.com/danielhanold/docket/internal/reposetup" +) + +// repository_repair_prbacklinks.go — `docket repository repair --pr-backlinks`: +// the one-time, human-confirmed repair of merged PRs whose docket:backlink block +// still names a change path that no longer exists (the active path of a change +// that has since been archived). It reads the pinned metadata corpus, reads the +// done changes' PR bodies in batches (≤25 per gh process), previews each PR with +// its current and corrected link, and — only under --yes, pinned to the previewed +// metadata revision — edits each one through the shared applyPRBacklinkRepoint, +// one PR at a time. A PR that cannot be read or edited is reported and skipped; +// it never aborts the batch. A second run finds nothing. + +// RepairGitHub is the GitHub seam this repair reads and edits merged PR bodies +// through. *githubcli.Client satisfies it. +type RepairGitHub interface { + DiscoverRepository(ctx context.Context, dir string) (githubcli.Repository, error) + ViewPullRequestsBatch(ctx context.Context, repo githubcli.Repository, numbers []int) (map[int]githubcli.BatchPRResult, error) + PRBodyEditor +} + +var _ RepairGitHub = (*githubcli.Client)(nil) + +// PRBacklinkRepair is one PR's row in the preview or the applied report. Current +// and Corrected are the backlink block's generated interior line (never authored +// prose). +type PRBacklinkRepair struct { + ID int `json:"id"` + PR int `json:"pr"` + Current string `json:"current,omitempty"` + Corrected string `json:"corrected,omitempty"` + Outcome string `json:"outcome"` + Message string `json:"message,omitempty"` +} + +// The closed per-PR outcomes. +const ( + PRBacklinkRepairPlanned = "planned" + PRBacklinkRepairRepointed = "repointed" + PRBacklinkRepairContended = "contended" + PRBacklinkRepairUnreadable = "unreadable" + PRBacklinkRepairFailed = "failed" +) + +// prRepairCandidate is one done change with a PR and the archive interior its +// PR's block must carry. +type prRepairCandidate struct { + id int + pr int + path string + interior string +} + +// prRepairPlanned is a planned row plus the bytes and revision the edit needs +// (kept out of the published row: body bytes are never reported). +type prRepairPlanned struct { + row PRBacklinkRepair + body string + revision string + path string + interior string +} + +func runPRBacklinkRepair(ctx context.Context, d SetupDeps, o RepairOptions) RepositoryRepairResult { + sc, metadataTip, refusal := repairPreflight(ctx, d) + if refusal != nil { + return *refusal + } + if d.GitHub == nil { + return repairInternalFailure(reposetup.StateHealthy, "wiring the GitHub client", + errors.New("no GitHub client is wired for --pr-backlinks")) + } + corpus, err := readCheckCorpus(ctx, d.Git, sc) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "reading the metadata corpus", err) + } + snap, ok := buildCorpusSnapshot(sc.cfg, corpus.records) + if !ok { + return repairInternalFailure(reposetup.StateHealthy, "building the metadata snapshot", + errors.New("the metadata corpus could not be built into a snapshot")) + } + cands, err := prRepairCandidates(snap, corpus.link) + if err != nil { + return repairInternalFailure(reposetup.StateHealthy, "rendering the archived backlinks", err) + } + if len(cands) == 0 { + return prRepairNoOp(metadataTip, nil) + } + repo, err := d.GitHub.DiscoverRepository(ctx, sc.repo.PrimaryWorktree) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "resolving the GitHub repository", err) + } + planned, skipped := planPRRepairs(ctx, d.GitHub, repo, cands) + if len(planned) == 0 { + return prRepairNoOp(metadataTip, skipped) + } + if !o.Authorized { + return prRepairConfirmationRequired(metadataTip, planned, skipped) + } + if migrateSourceMoved(o.ExpectedSource, metadataTip) { + return repairContended(metadataTip, o.ExpectedSource) + } + rows := append([]PRBacklinkRepair(nil), skipped...) + repointed, failed := 0, 0 + for _, p := range planned { + r := applyPRBacklinkRepoint(ctx, d.GitHub, repo, p.row.PR, p.body, p.revision, p.path, p.interior) + row := p.row + switch r.outcome { + case prBacklinkRepointed, prBacklinkAlready: + row.Outcome = PRBacklinkRepairRepointed + repointed++ + case prBacklinkContended: + row.Outcome, row.Message = PRBacklinkRepairContended, r.detail + failed++ + default: + row.Outcome, row.Message = PRBacklinkRepairFailed, r.detail + failed++ + } + rows = append(rows, row) + } + return prRepairApplied(metadataTip, rows, repointed, failed) +} + +// prRepairCandidates lists every done change carrying a PR reference, sorted by +// id, with the archive interior its PR's block must carry. +func prRepairCandidates(snap domain.Snapshot, link render.LinkContext) ([]prRepairCandidate, error) { + var out []prRepairCandidate + for _, c := range snap.Changes() { + if c.Status() != domain.StatusDone || !finalizeHasPRRef(c) { + continue + } + n, ok := parsePRNumber(c.PR().Value) + if !ok { + continue + } + block, err := render.BacklinkContent(c, link) + if err != nil { + return nil, fmt.Errorf("change %04d: %w", int(c.ID()), err) + } + out = append(out, prRepairCandidate{id: int(c.ID()), pr: n, path: c.Path(), interior: backlinkInterior(block)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) + return out, nil +} + +// planPRRepairs reads the candidates' PR bodies in ≤25-number batches and keeps +// the ones whose block does not name the archive path. A failed batch, an +// unresolved slot, or malformed markers is an `unreadable` row, never a silent drop. +func planPRRepairs(ctx context.Context, gh RepairGitHub, repo githubcli.Repository, cands []prRepairCandidate) ([]prRepairPlanned, []PRBacklinkRepair) { + byPR := map[int][]prRepairCandidate{} + var numbers []int + for _, c := range cands { + byPR[c.pr] = append(byPR[c.pr], c) + numbers = append(numbers, c.pr) + } + read := map[int]githubcli.BatchPRResult{} + failMsg := map[int]string{} + for _, chunk := range sweepChunkInts(sweepDedupeSortAsc(numbers), sweepPRBatchCap) { + res, err := gh.ViewPullRequestsBatch(ctx, repo, chunk) + for _, n := range chunk { + switch { + case err != nil: + failMsg[n] = "the pull-request batch could not be read: " + err.Error() + case !res[n].Found: + failMsg[n] = "the pull request could not be resolved" + default: + read[n] = res[n] + } + } + } + var planned []prRepairPlanned + var skipped []PRBacklinkRepair + for _, c := range cands { + br, ok := read[c.pr] + if !ok { + skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairUnreadable, Message: failMsg[c.pr]}) + continue + } + _, current, present, needs, err := planPRBacklinkRepoint([]byte(br.PR.Body), c.path, c.interior) + if err != nil { + skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairUnreadable, + Message: "the pull-request body carries a malformed docket:backlink block"}) + continue + } + if !present || !needs { + continue + } + planned = append(planned, prRepairPlanned{ + row: PRBacklinkRepair{ID: c.id, PR: c.pr, Current: current, Corrected: c.interior, Outcome: PRBacklinkRepairPlanned}, + body: br.PR.Body, + revision: br.PR.Revision, + path: c.path, + interior: c.interior, + }) + } + return planned, skipped +} + +func prRepairRows(planned []prRepairPlanned, skipped []PRBacklinkRepair) []PRBacklinkRepair { + rows := make([]PRBacklinkRepair, 0, len(planned)+len(skipped)) + for _, p := range planned { + rows = append(rows, p.row) + } + return append(rows, skipped...) +} + +func writePRRepairRows(b *strings.Builder, rows []PRBacklinkRepair) { + for _, r := range rows { + fmt.Fprintf(b, " PR #%d (change %04d): %s\n", r.PR, r.ID, r.Outcome) + if r.Current != "" { + fmt.Fprintf(b, " current: %s\n", r.Current) + } + if r.Corrected != "" { + fmt.Fprintf(b, " corrected: %s\n", r.Corrected) + } + if r.Message != "" { + fmt.Fprintf(b, " %s\n", r.Message) + } + } +} + +func prRepairConfirmationRequired(metadataTip string, planned []prRepairPlanned, skipped []PRBacklinkRepair) RepositoryRepairResult { + rows := prRepairRows(planned, skipped) + out := newRepairResult(ResultInvalidState, RepositoryRepairResult{ + RepositoryState: repairConfirmationRequiredState, + SourceRevision: metadataTip, + PRBacklinks: rows, + }) + var b strings.Builder + fmt.Fprintf(&b, "docket repository repair --pr-backlinks — preview (metadata %s)\n", metadataTip) + writePRRepairRows(&b, rows) + fmt.Fprintf(&b, "confirmation required: re-run with --yes to repoint these %d pull-request backlink(s)", len(planned)) + out.human = b.String() + return out +} + +func prRepairNoOp(metadataTip string, skipped []PRBacklinkRepair) RepositoryRepairResult { + out := newRepairResult(ResultNoOp, RepositoryRepairResult{ + RepositoryState: string(reposetup.StateHealthy), + SourceRevision: metadataTip, + PRBacklinks: skipped, + }) + var b strings.Builder + fmt.Fprintf(&b, "repository repair --pr-backlinks: no pull-request backlink to repoint at metadata %s\n", metadataTip) + writePRRepairRows(&b, skipped) + out.human = strings.TrimRight(b.String(), "\n") + return out +} + +func prRepairApplied(metadataTip string, rows []PRBacklinkRepair, repointed, failed int) RepositoryRepairResult { + result := ResultApplied + if repointed == 0 && failed > 0 { + result = ResultExternalFailed + } + out := newRepairResult(result, RepositoryRepairResult{ + RepositoryState: string(reposetup.StateHealthy), + SourceRevision: metadataTip, + PRBacklinks: rows, + }) + var b strings.Builder + fmt.Fprintf(&b, "repository repair --pr-backlinks: %d repointed, %d not repointed\n", repointed, failed) + writePRRepairRows(&b, rows) + out.human = strings.TrimRight(b.String(), "\n") + return out +} +``` + +Check that `newRepairResult` exists under that name (`grep -n "func newRepairResult" internal/app/repository_repair.go`) and that `repairInternalFailure` takes a `reposetup.State`. Use the real helpers; do not write parallel ones. + +- [ ] **Step 4: Implement the CLI side and its tests** + +In `internal/cli/repository.go`, add the package seam next to `repositoryRepairRunner`: + +```go +// repositoryRepairGitHub constructs the GitHub client `repository repair +// --pr-backlinks` reads and edits PR bodies through. It is called only when +// that flag is set; tests replace it. +var repositoryRepairGitHub = func() (app.RepairGitHub, error) { return githubcli.NewClient() } +``` + +`githubcli.NewClient()` returns `(*githubcli.Client, error)`. If Go rejects the direct return, assign it to a local first. In `newRepositoryRepairCommand`'s `RunE`, after building `deps`: + +```go + prBacklinks, _ := c.Flags().GetBool("pr-backlinks") + if prBacklinks { + gh, err := repositoryRepairGitHub() + if err != nil { + return err + } + deps.GitHub = gh + } +``` + +Then pass `PRBacklinks: prBacklinks` in all three `app.RepairOptions{…}` literals: the `--yes` call, the preview call, and the confirmed re-invocation. Register the flag with `cmd.Flags().Bool("pr-backlinks", false, "repoint merged pull requests whose change backlink names a path that no longer exists (reads and edits PR descriptions on GitHub)")`. Change the annotation and its comment to: + +```go + // metadata-write: one repair descendant published to the metadata branch + // under an exact lease. external-write: --pr-backlinks edits merged PR + // descriptions on GitHub. It never touches the local .docket worktree. + Annotations: capability("repository.repair", EffectExternalWrite, EffectMetadataWrite), +``` + +Add the `githubcli` import if it is missing. In `internal/cli/repository_test.go`, update `TestRepositoryRepairRegisteredWithCapability`: require the `pr-backlinks` flag, and expect effects `"external-write metadata-write"` (fix its doc comment to match). Add: + +```go +// TestRepositoryRepairGitHubOnlyWithPRBacklinks proves the GitHub client is +// built and wired only under --pr-backlinks, and the flag reaches the service. +func TestRepositoryRepairGitHubOnlyWithPRBacklinks(t *testing.T) { + built := 0 + oldGH := repositoryRepairGitHub + repositoryRepairGitHub = func() (app.RepairGitHub, error) { built++; return nil, nil } + var seen []app.RepairOptions + oldR := repositoryRepairRunner + repositoryRepairRunner = func(ctx context.Context, d app.SetupDeps, o app.RepairOptions) app.OperationResult { + seen = append(seen, o) + return fakeRepairResult{Envelope: app.NewEnvelope("repository.repair", app.ResultNoOp), source: "abc"} + } + defer func() { repositoryRepairGitHub = oldGH; repositoryRepairRunner = oldR }() + + runCLI(t, "repository", "repair", "--yes") + if built != 0 || len(seen) != 1 || seen[0].PRBacklinks { + t.Fatalf("plain repair built GitHub %d time(s) / opts %+v; want none", built, seen) + } + runCLI(t, "repository", "repair", "--pr-backlinks", "--yes") + if built != 1 || len(seen) != 2 || !seen[1].PRBacklinks || !seen[1].Authorized { + t.Fatalf("--pr-backlinks: built=%d opts=%+v", built, seen) + } +} +``` + +Also extend `TestRepositoryRepairInteractiveConfirmReinvokes`, or add a sibling test, so that with `--pr-backlinks` the confirmed re-invocation still carries `PRBacklinks: true`. + +- [ ] **Step 5: Run the tests to verify they pass, then mutation-test acceptance 4** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoRepair' ./internal/app/`, `go test -count=1 ./internal/app/ ./internal/cli/` +Expected: PASS. + +Mutation: back up `repository_repair.go`, change `if o.PRBacklinks {` to `if true {` (routine repair now runs the PR path), and rerun `-run '^TestIntegrationRepoRepairRoutine'`. Expected: FAIL (the forbidding fake is called). Restore with `mv -f` and rerun to PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/repository_facts.go internal/app/repository_repair.go internal/app/repository_repair_prbacklinks.go internal/app/repository_repair_integration_test.go internal/cli/repository.go internal/cli/repository_test.go +git commit -m "feat(repository): repair --pr-backlinks repoints merged PRs' dead change backlinks after a preview" +``` + +--- + +### Task 7: Living docs, bundle regeneration, and the whole-suite gate + +**Files:** +- Modify: `docs/concepts/finalize-sequencer.md`, `docs/reference/glossary.md`, `skills/docket-finalize-change/SKILL.md` +- Regenerate: `internal/assets/embedded/` (via `go generate ./internal/assets/`) + +**Interfaces:** +- Consumes: the behavior from Tasks 3–6, as it actually ships. +- Produces: docs that describe current behavior only. + +- [ ] **Step 1: Edit the docs (only where they would otherwise be wrong or silent about a command a reader runs)** + +`docs/concepts/finalize-sequencer.md`, in the close-out bullet: after "and retargets the backlinks in the change's spec, plan, and results files to the archived record." add: "It also repoints the backlink at the top of the merged pull request's description to the archived record. A description edit that fails leaves a `pr-backlink-pending` finding (the change stays `done`), and `docket finalize cleanup` and the maintenance sweep retry it." + +`docs/reference/glossary.md`, in "Derived view / generated block / backlink", after "Each has exactly one writer and is never hand-edited.": "When a change closes out, every one of these backlinks, the one in the merged PR's description included, is repointed to the archived record." In the `repository repair` entry (the paragraph starting "`repository repair` is the human-authorized repair path"), add after the existing code block: + +```markdown +`--pr-backlinks` repairs a different thing: merged pull requests whose description backlink still +names a change path that no longer exists. It reads those PR descriptions on GitHub, previews each +PR with its current and corrected link, and edits them one at a time only with `--yes`. A PR it +cannot read or edit is reported and skipped. + +```sh +docket repository repair --pr-backlinks # preview; add --yes to apply +``` +``` + +`skills/docket-finalize-change/SKILL.md`: in the `done-archived` bullet, change "artifact block + spec backlink + inline board rerendered" to "artifact block + spec backlink + inline board rerendered, and the merged PR description's backlink repointed to the archived record". Append to the `contended` / `blocked` / `unknown` bullet: "A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it." In the step-10 `finalize.cleanup` paragraph, change "repair a pending backlink first" to "repair a pending backlink (artifact files and the PR description) first". + +Do not cite this change, its PR, or its spec in any of these files. + +- [ ] **Step 2: Regenerate the embedded bundle and run the docs guards** + +```bash +go generate ./internal/assets/ +go run ./cmd/genassets -check -repo . +go test -count=1 ./internal/repoguard/ ./internal/assets/ +``` + +Expected: the generator rewrites only `internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md` (plus any index it maintains), `-check` passes, and the guards (`TestLivingDocsAlignment`, `TestCommentAnchorStyle`) PASS. If `-check` takes a different flag shape, use the one `cmd/genassets` documents. + +- [ ] **Step 3: Read-only smoke against the real repository (optional, preview only)** + +If `gh` is authenticated, run the preview from source against the real repository. This is a read and never `--yes`: + +```bash +go run ./cmd/docket repository repair --pr-backlinks --repo-dir /Users/homer/dev/docket --json > "${TMPDIR:-/tmp}/prbacklinks-preview.json"; echo "exit=$?" +jq '{result, repository_state, planned: [.pr_backlinks[]? | select(.outcome=="planned")] | length, unreadable: [.pr_backlinks[]? | select(.outcome=="unreadable")] | length}' "${TMPDIR:-/tmp}/prbacklinks-preview.json" +``` + +Expected: `repository_state` `confirmation-required` with a planned count of roughly the number of merged PRs (on the order of 250). Record the counts in your report. A `planned` count of 0 here is a red flag: report it, do not ignore it. **Never** add `--yes`. + +- [ ] **Step 4: Run the whole-suite gate** + +Run: `go run ./cmd/docket development test` +Expected: PASS. Read the budget report. Any `BUDGET WATCH:`, `PARALLEL-SENSITIVE:`, or `SERIAL CONFIRMED OVER BUDGET:` line for `tests/test_go_integration_githubcli_merge.sh`, `…_app_closeout.sh`, `…_app_archive.sh`, `…_app_cleanup.sh`, or `…_app_reporepair.sh` is a finding to report (and, for a serial-confirmed breach, to fix by sizing the row in `tests/runtime-budgets.tsv` per `tests/README.md`). + +- [ ] **Step 5: Commit** + +```bash +git add docs/concepts/finalize-sequencer.md docs/reference/glossary.md skills/docket-finalize-change/SKILL.md internal/assets/embedded +git commit -m "docs: describe repointing the merged PR's change backlink and repair --pr-backlinks" +``` + +--- + +## Human verification items (for the results file) + +These depend on facts that only exist outside the repository (learning `external-truth-needs-a-human-checkpoint`). The build cannot certify them. + +1. **A real body round-trip on a merged PR.** Task 1 confirms GitHub reports `viewerCanUpdate: true` for a merged PR. It does not prove that `gh pr edit --body-file -` on a merged PR stores the body byte-identically, so that the verify read equals the request. If GitHub normalized the body, every real edit would verify as `contended`. To certify: after this change merges, run the first real `finalize closeout`. The close-out PR's description should link to `docs/changes/archive/…`, and the result should carry no `pr-backlink-pending` finding. +2. **The one-time repair is the human's.** After merge and the binary rebuild, run `docket repository repair --pr-backlinks` (preview), check the planned list, then re-run with `--yes`. A second preview should report nothing to repoint. From c09174ff0dc6213f787069d3e85c7b7fb22d2fbe Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 20:52:19 -0400 Subject: [PATCH 02/13] feat(githubcli): edit a pull request's body by number under an exact revision Verification: $ gh --version gh version 2.101.0 (2026-09-15) https://github.com/cli/cli/releases/tag/v2.101.0 $ gh api graphql \ -f query='query($o:String!,$n:String!){repository(owner:$o,name:$n){pullRequest(number:250){number state viewerCanUpdate}}}' \ -f o=danielhanold -f n=docket {"data":{"repository":{"pullRequest":{"number":250,"state":"MERGED","viewerCanUpdate":true}}}} (exit 0) EditPullRequestBody is classified as a network WRITE site in TestEveryNetworkSiteIsReadWriteClassified and the client_test.go enumeration. --- internal/githubcli/bodyedit.go | 95 ++++++++++ .../githubcli/bodyedit_integration_test.go | 169 ++++++++++++++++++ internal/githubcli/client_test.go | 4 +- .../githubcli/network_classification_test.go | 1 + 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 internal/githubcli/bodyedit.go create mode 100644 internal/githubcli/bodyedit_integration_test.go diff --git a/internal/githubcli/bodyedit.go b/internal/githubcli/bodyedit.go new file mode 100644 index 000000000..eb0649652 --- /dev/null +++ b/internal/githubcli/bodyedit.go @@ -0,0 +1,95 @@ +package githubcli + +// This file owns EditPullRequestBody: the probe→act→verify body edit of one +// exact pull request by number, in any state (a merged PR included). It is the +// primitive close-out, cleanup, and `repository repair --pr-backlinks` use to +// repoint a merged PR's docket-owned backlink block at the archived change +// record. It mirrors retarget.go's fixed shape: +// +// 1. probe the PR by number for its current body and opaque revision; +// 2. a body already equal to the request is `already` — no edit (idempotency +// keyed on the promised remote state, never on the revision); +// 3. an edit is authorized only when ExpectedRevision equals the live revision; +// an empty or mismatched revision is `contended` and the PR is untouched; +// 4. `gh pr edit --repo --body-file -` carries the body on stdin +// only — never in argv or a diagnostic — and edits nothing else; and +// 5. a fresh by-number probe re-derives the postcondition: an equal body is +// `edited`, a different body is `contended` (a race, reported, never rolled +// back), and a probe that cannot be established is `unknown`. +// +// An errored probe is never read as clean absence (learning +// probe-error-is-not-clean-absence): a launch/timeout/cancel Failure or a +// non-zero exit is `unknown`, and `unknown` never authorizes the edit. + +import ( + "context" + "strconv" +) + +// bodyEditOp labels every Failure raised while editing a pull-request body. +const bodyEditOp = "edit-pull-request-body" + +// BodyEditOutcome is the closed set of body-edit outcomes. +type BodyEditOutcome string + +const ( + // BodyEdited: the body was replaced and the postcondition verified. + BodyEdited BodyEditOutcome = "edited" + // BodyAlready: the PR already carried exactly the requested body; no edit. + BodyAlready BodyEditOutcome = "already" + // BodyContended: the live revision diverged from ExpectedRevision, or the + // verified body differs from the request; the PR was not overwritten. + BodyContended BodyEditOutcome = "contended" + // BodyUnknown: an external probe could not establish the truth (or the input + // was invalid); nothing was authorized. The returned error is the diagnostic. + BodyUnknown BodyEditOutcome = "unknown" +) + +// EditPullRequestBody replaces one exact PR's body, idempotently. +// edited/already/contended are value outcomes returned with a nil error; unknown +// carries a typed *Failure. The PR snapshot is populated on edited/already. +func (c *Client) EditPullRequestBody(ctx context.Context, repo Repository, number int, expectedRevision, body string) (BodyEditOutcome, PullRequest, error) { + if err := validateRepository(repo); err != nil { + return BodyUnknown, PullRequest{}, newFailure(bodyEditOp, StageValidate, KindInvalidInput, "repository identity invalid: "+err.Error(), err) + } + if number <= 0 { + return BodyUnknown, PullRequest{}, newFailure(bodyEditOp, StageValidate, KindInvalidInput, "pull request number must be positive", nil) + } + + pr, err := c.ViewPullRequest(ctx, repo, number) + if err != nil { + return BodyUnknown, PullRequest{}, err + } + if pr.Body == body { + return BodyAlready, pr, nil + } + if expectedRevision == "" || expectedRevision != pr.Revision { + return BodyContended, PullRequest{}, nil + } + + _, mf := c.run(ctx, runRequest{ + op: bodyEditOp, + args: []string{ + "pr", "edit", strconv.Itoa(number), + "--repo", repo.Spec(), + "--body-file", "-", + }, + stdin: []byte(body), + network: true, + write: true, + }) + if mf != nil && mf.Stage == StageLaunch { + // gh never started; nothing was mutated. + return BodyUnknown, PullRequest{}, mf + } + // A zero exit, a non-zero exit, a timeout, or a cancel may all have reached + // GitHub: resolve every one by the same fresh verify probe. + after, err := c.ViewPullRequest(ctx, repo, number) + if err != nil { + return BodyUnknown, PullRequest{}, err + } + if after.Body == body { + return BodyEdited, after, nil + } + return BodyContended, PullRequest{}, nil +} diff --git a/internal/githubcli/bodyedit_integration_test.go b/internal/githubcli/bodyedit_integration_test.go new file mode 100644 index 000000000..df6dcb60a --- /dev/null +++ b/internal/githubcli/bodyedit_integration_test.go @@ -0,0 +1,169 @@ +//go:build integration + +package githubcli + +import ( + "context" + "strings" + "testing" +) + +// EditPullRequestBody drives probe→act→verify for one merged PR's body through +// the protocol-faithful fake gh. Each case asserts the outcome AND the witness +// log (whether an edit ran, and what it carried on stdin), so a green result +// can never hide an unexercised or an extra external mutation. + +const ( + bodyOld = "\n> ↩ old\n\n\nAuthored prose.\r\n" + bodyNew = "\n> ↩ new\n\n\nAuthored prose.\r\n" +) + +func mergedPRJSON(body string) string { + return ensPRJSON(7, "MERGED", false, ensHead, ensHeadOid, "main", ensTitle, body) +} + +func editRecord(t *testing.T, log *witnessLog) (invocationRecord, int) { + t.Helper() + var found invocationRecord + n := 0 + for _, r := range log.records(t) { + if len(r.Argv) >= 2 && r.Argv[0] == "pr" && r.Argv[1] == "edit" { + found = r + n++ + } + } + return found, n +} + +func TestIntegrationMergeBodyEditProbeActVerify(t *testing.T) { + oldRev := mustDecodeOne(t, mergedPRJSON(bodyOld)).Revision + + t.Run("edited", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm(mergedPRJSON(bodyNew), 0), + }}) + out, pr, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyEdited { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyEdited) + } + if pr.Body != bodyNew { + t.Fatalf("verified body differs from the request") + } + rec, n := editRecord(t, log) + if n != 1 { + t.Fatalf("pr edit issued %d times, want 1", n) + } + if rec.Stdin != bodyNew { + t.Fatalf("edit stdin = %q, want the exact requested body", rec.Stdin) + } + joined := strings.Join(rec.Argv, "\x00") + for _, want := range []string{"7", "--repo", "acme/widget", "--body-file", "-"} { + if !strings.Contains(joined, want) { + t.Errorf("edit argv %v lacks %q", rec.Argv, want) + } + } + for _, a := range rec.Argv { + if strings.Contains(a, "Authored prose") { + t.Fatalf("body bytes leaked into argv: %v", rec.Argv) + } + } + for _, forbidden := range []string{"--base", "--title"} { + if strings.Contains(joined, forbidden) { + t.Errorf("a body edit must not carry %s: %v", forbidden, rec.Argv) + } + } + }) + + t.Run("already", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyNew), 0)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyAlready { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyAlready) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times on an already-correct body, want 0", n) + } + }) + + t.Run("contended-revision-drift", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyOld), 0)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, "sha256:stale", bodyNew) + if err != nil || out != BodyContended { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyContended) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times on revision drift, want 0", n) + } + }) + + t.Run("empty-revision-is-contended", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm(mergedPRJSON(bodyOld), 0)}}) + out, _, _ := c.EditPullRequestBody(context.Background(), retRepo(), 7, "", bodyNew) + if out != BodyContended { + t.Fatalf("outcome = %q, want %q", out, BodyContended) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("an empty revision authorized %d edits, want 0", n) + } + }) + + t.Run("probe-error-unknown", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{Invocations: []fakeArm{retViewArm("", 1)}}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + if _, n := editRecord(t, log); n != 0 { + t.Fatalf("pr edit issued %d times after a probe error, want 0", n) + } + }) + + t.Run("verify-shows-different-body", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm(mergedPRJSON("a human rewrote it\n"), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyContended { + t.Fatalf("outcome = %q err=%v, want %q", out, err, BodyContended) + } + }) + + t.Run("nonzero-edit-exit-resolved-by-verify", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(1), + retViewArm(mergedPRJSON(bodyNew), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if err != nil || out != BodyEdited { + t.Fatalf("a landed edit with a lost response must verify as %q, got %q err=%v", BodyEdited, out, err) + } + }) + + t.Run("verify-error-unknown", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm("", 1), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + }) + + t.Run("invalid-number", func(t *testing.T) { + c, log := newFakeClient(t, fakeScenario{}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 0, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + if len(log.records(t)) != 0 { + t.Fatalf("an invalid request must spawn no gh process") + } + }) +} diff --git a/internal/githubcli/client_test.go b/internal/githubcli/client_test.go index a8adc86ac..8f8313869 100644 --- a/internal/githubcli/client_test.go +++ b/internal/githubcli/client_test.go @@ -22,6 +22,7 @@ import ( // ensure.go verifyViewByNumber pr view READ // retarget.go RetargetPullRequest pr edit --base WRITE // retarget.go viewPullRequest pr view READ (verifyRetarget reprobe) +// bodyedit.go EditPullRequestBody pr edit --body-file - WRITE // comment.go EnsureComment pr comment WRITE // comment.go FindComment pr view --comments READ // mergemethod.go merge-method probes repo/api reads READ @@ -30,7 +31,8 @@ import ( // // Writes are the gh invocations that MUTATE GitHub state: the merge in // MergePullRequest (issueMerge), the create/edit in EnsurePullRequest/mutateAndVerify, the -// edit --base in RetargetPullRequest, and the comment post in EnsureComment. +// edit --base in RetargetPullRequest, the body edit in EditPullRequestBody, and +// the comment post in EnsureComment. // Their verification/reprobe queries (verifyMerge, verifyPostMutation, // viewPullRequest, FindComment) and every discovery probe are reads. diff --git a/internal/githubcli/network_classification_test.go b/internal/githubcli/network_classification_test.go index 754ce1eaa..24f142293 100644 --- a/internal/githubcli/network_classification_test.go +++ b/internal/githubcli/network_classification_test.go @@ -40,6 +40,7 @@ func TestEveryNetworkSiteIsReadWriteClassified(t *testing.T) { "ViewPullRequest": false, // pr view "FindOpenPullRequestsByHead": false, // pr list --head "RetargetPullRequest": true, // pr edit --base + "EditPullRequestBody": true, // pr edit --body-file - "DiscoverRepository": false, // repo view } assertNetworkSitesClassified(t, classified) From be2e48d1016b002b87ed2ba1456e5dcf1e4152a0 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 20:53:49 -0400 Subject: [PATCH 03/13] feat(app): one shared decision and edit sequence for repointing a PR's change backlink --- internal/app/finalize_context.go | 5 + internal/app/pr_backlink_repoint.go | 151 ++++++++++++++++ internal/app/pr_backlink_repoint_test.go | 212 +++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 internal/app/pr_backlink_repoint.go create mode 100644 internal/app/pr_backlink_repoint_test.go diff --git a/internal/app/finalize_context.go b/internal/app/finalize_context.go index bbf6df663..844f708e6 100644 --- a/internal/app/finalize_context.go +++ b/internal/app/finalize_context.go @@ -141,6 +141,11 @@ type FinalizeDeps struct { // FinalizeDeps carries it too; only the sweep (MaintenanceSweep) READS it, so // in every other operation it is populated but inert. PRBatch SweepPRBatchReader + // PRBody is the narrow GitHub seam close-out and cleanup repoint a merged + // PR's docket:backlink block through (pr_backlink_repoint.go). Production + // leaves it nil: prBodyEditor falls back to GitHub when that client satisfies + // FinalizePRBody (the real *githubcli.Client does). A test injects a fake. + PRBody FinalizePRBody // Gate is the local-gate composition seam finalize rebase drives after a // completed rebase (Task 8): it launches the resolved suite in the feature // workspace, observes it to a terminal within the observation budget, and maps diff --git a/internal/app/pr_backlink_repoint.go b/internal/app/pr_backlink_repoint.go new file mode 100644 index 000000000..586e017bf --- /dev/null +++ b/internal/app/pr_backlink_repoint.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" +) + +// This file is the one place that decides whether a merged pull request's +// docket:backlink block must be repointed at the change's archived record, and +// the one edit sequence that does it. Close-out (runCloseoutPRBacklinkLeg), +// cleanup (finalizeCleanupPRBacklinkRepair), the sweep assessment +// (sweepAssessPRBacklinkLeg), and `repository repair --pr-backlinks` all call +// it; none keeps a copy of the predicate. +// +// The decision is path-keyed: a block whose interior already names the archive +// path is a no-op whatever its title wording, and a body with no block is never +// given one. Only the docket-owned block is replaced, through the loss-preserving +// document patch path, so every authored byte (CRLF endings and the build- +// evidence block included) survives. The leg is best-effort: a failure is a +// pr-backlink-pending warning, never a stop. PR body bytes never reach a finding. + +// ReasonPRBacklinkPending: a merged PR's backlink block could not be repointed at +// the archived record (a read, edit, or marker problem, or a lost race); the +// change stays truthfully done and cleanup / the sweep retry it. +const ReasonPRBacklinkPending = "pr-backlink-pending" + +// The closed outcomes of one repoint attempt. +const ( + prBacklinkRepointed = "repointed" // the block now names the archive path + prBacklinkAlready = "already" // the block already named it; no edit + prBacklinkNoBlock = "no-block" // a hand-written body; never given a block + prBacklinkContended = "contended" // the body moved under us; not overwritten + prBacklinkUnknown = "unknown" // unreadable, malformed, or unverified +) + +// PRBodyEditor is the one GitHub write the repoint needs. +type PRBodyEditor interface { + EditPullRequestBody(ctx context.Context, repo githubcli.Repository, number int, expectedRevision, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) +} + +// FinalizePRBody is the read+write seam close-out and cleanup use. +type FinalizePRBody interface { + ViewPullRequest(ctx context.Context, repo githubcli.Repository, number int) (githubcli.PullRequest, error) + PRBodyEditor +} + +// The production client satisfies the seam, so prBodyEditor's fallback is live. +var _ FinalizePRBody = (*githubcli.Client)(nil) + +// prBodyEditor returns the injected PRBody seam, else deps.GitHub when it +// satisfies FinalizePRBody (production: the real client), else nil (a test +// fake GitHub that cannot edit bodies — the leg is then not run). +func prBodyEditor(deps FinalizeDeps) FinalizePRBody { + if deps.PRBody != nil { + return deps.PRBody + } + if ed, ok := deps.GitHub.(FinalizePRBody); ok { + return ed + } + return nil +} + +// prBacklinkResult is one attempt's outcome, the block interior it found +// (generated text only), and a redacted diagnostic. +type prBacklinkResult struct { + outcome string + current string + detail string +} + +// planPRBacklinkRepoint is the pure decision over one PR body. present reports +// whether a docket:backlink block exists; needs reports whether its interior +// fails to name archivePath; updated is the body with only that block's interior +// replaced by archivedInterior (the input bytes unchanged when !needs). A parse +// or patch failure — malformed, unbalanced, or nested markers — is an error. +func planPRBacklinkRepoint(body []byte, archivePath, archivedInterior string) (updated []byte, current string, present, needs bool, err error) { + doc, err := document.Parse(body) + if err != nil { + return nil, "", false, false, err + } + blk, ok := doc.Block(backlinkBlockName) + if !ok { + return body, "", false, false, nil + } + current = strings.TrimRight(string(body[blk.Interior.Start:blk.Interior.End]), "\r\n") + if strings.Contains(current, archivePath) { + return body, current, true, false, nil + } + var ps document.PatchSet + ps.ReplaceBlock(backlinkBlockName, archivedInterior) + updated, err = doc.Apply(ps) + if err != nil { + return nil, current, true, false, err + } + return updated, current, true, true, nil +} + +// repointPRBacklink reads one PR by number and repoints its block. +func repointPRBacklink(ctx context.Context, ed FinalizePRBody, repo githubcli.Repository, number int, archivePath, archivedInterior string) prBacklinkResult { + pr, err := ed.ViewPullRequest(ctx, repo, number) + if err != nil { + return prBacklinkResult{outcome: prBacklinkUnknown, detail: "the pull-request body could not be read: " + err.Error()} + } + return applyPRBacklinkRepoint(ctx, ed, repo, number, pr.Body, pr.Revision, archivePath, archivedInterior) +} + +// applyPRBacklinkRepoint repoints a body already in hand (the repair reads it in +// a batch), editing only under the revision that body was read at. +func applyPRBacklinkRepoint(ctx context.Context, ed PRBodyEditor, repo githubcli.Repository, number int, body, revision, archivePath, archivedInterior string) prBacklinkResult { + updated, current, present, needs, err := planPRBacklinkRepoint([]byte(body), archivePath, archivedInterior) + if err != nil { + return prBacklinkResult{outcome: prBacklinkUnknown, detail: "the pull-request body carries a malformed docket:backlink block"} + } + if !present { + return prBacklinkResult{outcome: prBacklinkNoBlock} + } + if !needs { + return prBacklinkResult{outcome: prBacklinkAlready, current: current} + } + out, _, err := ed.EditPullRequestBody(ctx, repo, number, revision, string(updated)) + switch { + case err != nil: + return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit could not be verified: " + err.Error()} + case out == githubcli.BodyEdited || out == githubcli.BodyAlready: + return prBacklinkResult{outcome: prBacklinkRepointed, current: current} + case out == githubcli.BodyContended: + return prBacklinkResult{outcome: prBacklinkContended, current: current, detail: "the body changed since it was read"} + default: + return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit outcome is unknown"} + } +} + +// prBacklinkFinding maps an attempt to a retryable warning, or nil when the PR is +// in its promised state (repointed, already, or a block-less body). +func prBacklinkFinding(id, number int, r prBacklinkResult) *StatusFinding { + switch r.outcome { + case prBacklinkRepointed, prBacklinkAlready, prBacklinkNoBlock: + return nil + } + msg := fmt.Sprintf("change %04d is done, but PR #%d's change backlink was not repointed to the archived record (%s)", id, number, r.outcome) + if r.detail != "" { + msg += ": " + r.detail + } + msg += "; cleanup and the maintenance sweep will retry it" + return &StatusFinding{Code: ReasonPRBacklinkPending, Severity: string(domain.SeverityWarning), Message: msg} +} diff --git a/internal/app/pr_backlink_repoint_test.go b/internal/app/pr_backlink_repoint_test.go new file mode 100644 index 000000000..cd4be89d1 --- /dev/null +++ b/internal/app/pr_backlink_repoint_test.go @@ -0,0 +1,212 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/githubcli" +) + +// fakePRBody is a scriptable FinalizePRBody keyed by PR number. Its revision is +// a digest of the current body, so a body change moves the revision exactly as +// GitHub's does. Shared with the integration tests (untagged file). +type fakePRBody struct { + bodies map[int]string + viewErr error + editErr error + editOut githubcli.BodyEditOutcome // "" applies the edit; any other value is returned as-is + views int + edits int +} + +func newFakePRBody(bodies map[int]string) *fakePRBody { return &fakePRBody{bodies: bodies} } + +func (f *fakePRBody) rev(n int) string { + sum := sha256.Sum256([]byte(f.bodies[n])) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (f *fakePRBody) ViewPullRequest(_ context.Context, _ githubcli.Repository, n int) (githubcli.PullRequest, error) { + f.views++ + if f.viewErr != nil { + return githubcli.PullRequest{}, f.viewErr + } + b, ok := f.bodies[n] + if !ok { + return githubcli.PullRequest{}, errors.New("fake: no such pull request") + } + return githubcli.PullRequest{Number: n, State: githubcli.StateMerged, Body: b, Revision: f.rev(n)}, nil +} + +func (f *fakePRBody) EditPullRequestBody(_ context.Context, _ githubcli.Repository, n int, rev, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) { + f.edits++ + if f.editErr != nil { + return githubcli.BodyUnknown, githubcli.PullRequest{}, f.editErr + } + if f.editOut != "" { + return f.editOut, githubcli.PullRequest{}, nil + } + if rev != f.rev(n) { + return githubcli.BodyContended, githubcli.PullRequest{}, nil + } + f.bodies[n] = body + return githubcli.BodyEdited, githubcli.PullRequest{Number: n, Body: body, Revision: f.rev(n)}, nil +} + +// prBodyWithActiveBacklink is a PR body as pr.publish leaves it: the backlink +// block (fallback form, naming the ACTIVE record path) at the top, then +// authored prose and a build-evidence-shaped block that must survive. +func prBodyWithActiveBacklink(activePath, authored string) string { + return "\n" + + "> ↩ **Change 0005 — A change** — `" + activePath + "`\n" + + "\n\n" + + authored + "\n\n" + + "\nevidence\n\n" +} + +const ( + tActive = "docs/changes/active/0005-widget.md" + tArchive = "docs/changes/archive/2026-08-18-0005-widget.md" + tInterior = "> ↩ **Change 0005 — A change** — `" + tArchive + "`" +) + +// outsideBlock returns body with the backlink block's interior removed, so two +// bodies can be compared on every byte the repoint must not touch. +func outsideBlock(t *testing.T, body string) string { + t.Helper() + start := strings.Index(body, "-->") + end := strings.Index(body, "\n" + + "> ↩ **Change 0005 — An OLDER title** — `" + tArchive + "`\n" + + "\n\nprose\n" + _, _, present, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || !present || needs { + t.Fatalf("present=%v needs=%v err=%v, want present no-work", present, needs, err) + } +} + +func TestPlanPRBacklinkRepointNoBlockIsNeverGivenOne(t *testing.T) { + body := "A hand-written PR description with no docket block.\n" + updated, _, present, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || present || needs { + t.Fatalf("present=%v needs=%v err=%v, want absent no-work", present, needs, err) + } + if string(updated) != body { + t.Fatalf("a block-less body was rewritten") + } +} + +func TestPlanPRBacklinkRepointPreservesCRLFProse(t *testing.T) { + body := strings.ReplaceAll(prBodyWithActiveBacklink(tActive, "Line one.\nLine two."), "\n", "\r\n") + updated, _, _, needs, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior) + if err != nil || !needs { + t.Fatalf("needs=%v err=%v, want a repoint of a CRLF body", needs, err) + } + if outsideBlock(t, string(updated)) != outsideBlock(t, body) { + t.Fatalf("CRLF bytes outside the block changed:\nbefore %q\nafter %q", body, string(updated)) + } +} + +func TestPlanPRBacklinkRepointMalformedMarkersIsError(t *testing.T) { + body := "\n> ↩ dangling\n\nprose\n" + if _, _, _, _, err := planPRBacklinkRepoint([]byte(body), tArchive, tInterior); err == nil { + t.Fatal("a dangling backlink marker must be an error (unknown), never a clean no-op") + } +} + +func TestRepointPRBacklinkEditsOnceThenIsIdempotent(t *testing.T) { + ed := newFakePRBody(map[int]string{7: prBodyWithActiveBacklink(tActive, "prose")}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkRepointed || ed.edits != 1 { + t.Fatalf("first = %+v edits=%d, want repointed once", r, ed.edits) + } + r = repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkAlready || ed.edits != 1 { + t.Fatalf("second = %+v edits=%d, want already with no new edit", r, ed.edits) + } + if prBacklinkFinding(5, 7, r) != nil { + t.Fatal("an already-correct PR must produce no finding") + } +} + +func TestRepointPRBacklinkMalformedNeverEdits(t *testing.T) { + ed := newFakePRBody(map[int]string{7: "\nbad\n"}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkUnknown || ed.edits != 0 { + t.Fatalf("malformed = %+v edits=%d, want unknown with no edit", r, ed.edits) + } + if f := prBacklinkFinding(5, 7, r); f == nil || f.Code != ReasonPRBacklinkPending { + t.Fatalf("malformed must surface a %s finding, got %+v", ReasonPRBacklinkPending, f) + } +} + +func TestRepointPRBacklinkFailuresArePendingFindings(t *testing.T) { + for name, ed := range map[string]*fakePRBody{ + "view-error": {bodies: map[int]string{}, viewErr: errors.New("gh: network down")}, + "edit-error": {bodies: map[int]string{7: prBodyWithActiveBacklink(tActive, "p")}, editErr: errors.New("gh: 502")}, + "contended": {bodies: map[int]string{7: prBodyWithActiveBacklink(tActive, "p")}, editOut: githubcli.BodyContended}, + } { + t.Run(name, func(t *testing.T) { + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + f := prBacklinkFinding(5, 7, r) + if f == nil || f.Code != ReasonPRBacklinkPending || !strings.Contains(f.Message, "#7") { + t.Fatalf("%s: finding = %+v, want a %s finding naming PR #7", name, f, ReasonPRBacklinkPending) + } + if strings.Contains(f.Message, "prose") || strings.Contains(f.Message, "Authored") { + t.Fatalf("%s: the finding leaked PR body bytes: %q", name, f.Message) + } + }) + } +} + +func TestRepointPRBacklinkNoBlockNoEditNoFinding(t *testing.T) { + ed := newFakePRBody(map[int]string{7: "hand-written\n"}) + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkNoBlock || ed.edits != 0 || prBacklinkFinding(5, 7, r) != nil { + t.Fatalf("no-block = %+v edits=%d, want no edit and no finding", r, ed.edits) + } +} + +func TestPRBodyEditorFallsBackToTheGitHubClient(t *testing.T) { + if prBodyEditor(FinalizeDeps{GitHub: &githubcli.Client{}}) == nil { + t.Fatal("a *githubcli.Client in deps.GitHub must serve as the PR-body editor (production wiring)") + } + if prBodyEditor(FinalizeDeps{}) != nil { + t.Fatal("no editor is wired when neither PRBody nor a capable GitHub client is present") + } + ed := newFakePRBody(nil) + if prBodyEditor(FinalizeDeps{PRBody: ed}) != FinalizePRBody(ed) { + t.Fatal("an explicit PRBody seam wins") + } +} From 86de60e6c0097d99010af4e7b16338f22c1ae134 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 20:58:26 -0400 Subject: [PATCH 04/13] feat(finalize): close-out repoints each merged PR's change backlink at its archive path --- internal/app/finalize_closeout.go | 48 +++++++- .../app/finalize_closeout_integration_test.go | 114 +++++++++++++++++- internal/cli/finalize.go | 9 +- internal/cli/finalize_test.go | 3 + 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index 41bc368bb..cc12c7eb3 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -66,7 +66,11 @@ import ( // not terminal publishing: it copies no metadata record and edits no authored // bytes. A failed or contended second leg leaves the change truthfully `done` and // emits a typed final-backlink-pending finding; its idempotency is keyed on the -// remote block bytes (a block already pointing at the archive path is a no-op). +// remote block bytes (a block already pointing at the archive path is a no-op). A +// third, GitHub-side leg repoints each archived target's merged PR description +// block at its archive path (runCloseoutPRBacklinkLeg); a failed edit leaves the +// change truthfully `done` with a pr-backlink-pending warning that cleanup and +// the sweep retry. // OperationFinalizeCloseout is the operation key the metadata closeout // transaction records in its result envelope and trailer. @@ -619,6 +623,10 @@ func closeoutIntegrationDestination(ctx context.Context, deps FinalizeDeps, cc * if finding := runCloseoutBacklinkLeg(ctx, deps, cc, targets, archiveDate); finding != nil { res.Findings = append(res.Findings, *finding) } + // Follow-up: repoint each archived target's merged PR backlink block at its + // archive path (a GitHub edit, best-effort). A failed edit leaves the change + // truthfully done and emits a retryable pr-backlink-pending warning. + res.Findings = append(res.Findings, runCloseoutPRBacklinkLeg(ctx, deps, cc, ghRepo, targets)...) return res } @@ -899,6 +907,44 @@ func runCloseoutBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeout } } +// runCloseoutPRBacklinkLeg repoints, for every archived target (the root and +// each carried descendant), its own merged PR's docket:backlink block at its own +// archive path. It is independent of the integration-ref backlink leg and never +// changes the close-out disposition: each target that did not reach its promised +// state contributes one pr-backlink-pending warning. No editor wired (a test +// GitHub fake) runs nothing. +func runCloseoutPRBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, targets []closeoutTarget) []StatusFinding { + ed := prBodyEditor(deps) + if ed == nil { + return nil + } + var out []StatusFinding + for _, tg := range targets { + c, found := cc.snap.Change(domain.ChangeID(tg.id)) + if found != domain.LookupFound { + continue + } + number, ok := parsePRNumber(c.PR().Value) + if !finalizeHasPRRef(c) || !ok { + continue + } + src, ok := cc.sources[tg.activePath] + if !ok { + out = append(out, *prBacklinkFinding(tg.id, number, prBacklinkResult{outcome: prBacklinkUnknown, detail: "the record's source bytes were not in hand"})) + continue + } + interior, err := archivedBacklinkInterior(cc.eff, tg.archivePath, src, cc.link) + if err != nil { + out = append(out, *prBacklinkFinding(tg.id, number, prBacklinkResult{outcome: prBacklinkUnknown, detail: "the archived backlink could not be rendered"})) + continue + } + if f := prBacklinkFinding(tg.id, number, repointPRBacklink(ctx, ed, ghRepo, number, tg.archivePath, interior)); f != nil { + out = append(out, *f) + } + } + return out +} + // rootIDOf returns the root target's id. func (cc *closeoutContext) rootIDOf(targets []closeoutTarget) int { if len(targets) > 0 { diff --git a/internal/app/finalize_closeout_integration_test.go b/internal/app/finalize_closeout_integration_test.go index d16b7122d..e730b93e9 100644 --- a/internal/app/finalize_closeout_integration_test.go +++ b/internal/app/finalize_closeout_integration_test.go @@ -5,10 +5,12 @@ package app import ( "context" "errors" - "github.com/danielhanold/docket/internal/githubcli" "sort" "strings" "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" ) // TestCloseoutBacklinkLegDocketMode proves the docket-mode split: the metadata @@ -695,6 +697,27 @@ func TestIntegrationFinalizeArchiveRootCarry(t *testing.T) { assertBothArchived(t, f, res) }) + t.Run("repoints-each-carried-pr-backlink", func(t *testing.T) { + f := seed(t, "stacked-merged") + childMerge := f.carryOntoRootFeature(t, map[string]string{"gadget.txt": "gadget work\n"}) + mergeCommit := f.mergeIntoBase(t) + f.fetchAllIntoInvocation(t) + pr := newFakePRBody(map[int]string{ + closeoutPR: artifactWithBacklink(groomPath(5, "widget"), "Root", "root prose"), + 8: artifactWithBacklink(groomPath(6, "gadget"), "Child", "child prose"), + }) + deps := f.closeoutDeps(rootFake(f, mergeCommit, childMerge)) + deps.PRBody = pr + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + assertBothArchived(t, f, res) + if got := pr.bodies[closeoutPR]; !strings.Contains(got, "docs/changes/archive/2026-08-18-0005-widget.md") { + t.Errorf("root PR not repointed to its own archive path:\n%s", got) + } + if got := pr.bodies[8]; !strings.Contains(got, "docs/changes/archive/2026-08-18-0006-gadget.md") || strings.Contains(got, "0005-widget") { + t.Errorf("descendant PR not repointed to ITS OWN archive path:\n%s", got) + } + }) + t.Run("one-unproven-descendant-keeps-root-recoverable", func(t *testing.T) { f := seed(t, "implemented") // NOT stacked-merged: carry unproven mergeCommit := f.mergeIntoBase(t) @@ -1328,3 +1351,92 @@ func TestIntegrationFinalizeCloseoutUnrelatedInvalidRecordCarriedDescendant(t *t } }) } + +// TestIntegrationFinalizeCloseoutPRBacklinkRepointed proves close-out repoints +// the merged PR's backlink block at the archive path, keeps every authored byte, +// and is idempotent on replay. +func TestIntegrationFinalizeCloseoutPRBacklinkRepointed(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + recPath := groomPath(f.id, f.slug) + before := artifactWithBacklink(recPath, "Summary", "Authored PR prose.") + pr := newFakePRBody(map[int]string{closeoutPR: before}) + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { + t.Fatalf("closeout = %q disp %q (%s)", res.Result, res.Disposition, res.Message) + } + for _, fd := range res.Findings { + if fd.Code == ReasonPRBacklinkPending { + t.Fatalf("the PR-body leg did not land: %+v", fd) + } + } + after := pr.bodies[closeoutPR] + if !strings.Contains(after, res.ArchivePath) || strings.Contains(after, "`"+recPath+"`") { + t.Fatalf("PR body not repointed to %q:\n%s", res.ArchivePath, after) + } + if !strings.HasSuffix(after, "# Summary\n\nAuthored PR prose.\n") { + t.Fatalf("authored PR bytes changed:\n%s", after) + } + if pr.edits != 1 { + t.Fatalf("edits = %d, want 1", pr.edits) + } + + // Replay: the promised state already holds; no further edit. + again := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if again.Disposition != CloseoutDispAlready || pr.edits != 1 { + t.Fatalf("replay = disp %q edits=%d, want already with no new edit", again.Disposition, pr.edits) + } +} + +// TestIntegrationFinalizeCloseoutPRBacklinkFailureIsPendingOnly proves a failed +// GitHub edit never changes close-out's disposition: the change is done and +// archived, with one pr-backlink-pending warning. +func TestIntegrationFinalizeCloseoutPRBacklinkFailureIsPendingOnly(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(groomPath(f.id, f.slug), "Summary", "p")}) + pr.editErr = errors.New("gh: HTTP 502") + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { + t.Fatalf("a failed PR edit changed close-out: %q disp %q", res.Result, res.Disposition) + } + n := 0 + for _, fd := range res.Findings { + if fd.Code == ReasonPRBacklinkPending { + n++ + if fd.Severity != string(domain.SeverityWarning) { + t.Errorf("finding severity = %q, want warning", fd.Severity) + } + } + } + if n != 1 { + t.Fatalf("want exactly one %s finding, got %d: %+v", ReasonPRBacklinkPending, n, res.Findings) + } +} + +// TestIntegrationFinalizeCloseoutPRBacklinkNoBlockUntouched proves a +// hand-written PR body is never given a block and produces no finding. +func TestIntegrationFinalizeCloseoutPRBacklinkNoBlockUntouched(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + mergeCommit := f.mergeIntoBase(t) + pr := newFakePRBody(map[int]string{closeoutPR: "Hand-written, no docket block.\n"}) + deps := f.closeoutDeps(f.baselineMergedFake(f.head, mergeCommit)) + deps.PRBody = pr + + res := FinalizeCloseout(context.Background(), deps, f.repo.invocation, f.id, CloseoutNotes{}) + if res.Disposition != CloseoutDispDoneArchived || pr.edits != 0 { + t.Fatalf("disp %q edits=%d, want done-archived with no edit", res.Disposition, pr.edits) + } + if pr.bodies[closeoutPR] != "Hand-written, no docket block.\n" { + t.Fatalf("a block-less body was changed") + } +} diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index 9b6db5816..5ab8931cb 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -101,10 +101,11 @@ func newFinalizeCloseoutSubcommand(setResult func(app.OperationResult)) *cobra.C Use: "closeout", Short: "Close out a merged change: mark done and archive, mark stacked-merged, or carry a stack root", Args: cobra.NoArgs, - // metadata-write only: archive relocation + backlink retarget are - // metadata-branch transactions; the PR/merge reprobes are read-only, and - // it pushes no feature ref. - Annotations: capability("finalize.closeout", EffectMetadataWrite), + // metadata-write: archive relocation + backlink retarget are + // metadata-branch transactions. external-write: the merged PR's + // description backlink is repointed at the archived record (a GitHub + // edit). It pushes no feature ref. + Annotations: capability("finalize.closeout", EffectExternalWrite, EffectMetadataWrite), } decode := declareJSONFile[app.CloseoutNotes](cmd, "input") cmd.RunE = func(c *cobra.Command, _ []string) error { diff --git a/internal/cli/finalize_test.go b/internal/cli/finalize_test.go index efdbb4de9..f086c40f9 100644 --- a/internal/cli/finalize_test.go +++ b/internal/cli/finalize_test.go @@ -503,6 +503,9 @@ func TestFinalizeCloseoutRegistered(t *testing.T) { t.Errorf("finalize closeout must not carry a --%s flag", forbidden) } } + if got := cmd.Annotations[capAnnotationEffects]; got != "external-write metadata-write" { + t.Errorf("finalize closeout effects = %q, want %q", got, "external-write metadata-write") + } } // TestFinalizeCloseoutAssetIndependent guards the install.go registration: the From 7eafa1fe976decb3fd7b3c9aae2785788e0c40fc Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:01:09 -0400 Subject: [PATCH 05/13] feat(finalize): cleanup retries a pending PR backlink repoint --- internal/app/finalize_cleanup.go | 31 ++++++++++-- .../app/finalize_cleanup_integration_test.go | 48 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/internal/app/finalize_cleanup.go b/internal/app/finalize_cleanup.go index cf2ebadea..237bf5a3d 100644 --- a/internal/app/finalize_cleanup.go +++ b/internal/app/finalize_cleanup.go @@ -23,9 +23,9 @@ import ( // // `finalize cleanup` runs an ordered suffix over one final change: it reloads // the archived/stacked state and the verified merge destination; repairs the -// final backlinks first when needed; removes the feature checkout through the -// landed manifest-fact-driven workspace.Cleanup (never a base recomputed from the -// now-archived record); deletes the LOCAL feature ref only when the exact +// final backlinks (and the merged PR's description backlink) first when needed; +// removes the feature checkout through the landed manifest-fact-driven +// workspace.Cleanup (never a base recomputed from the now-archived record); deletes the LOCAL feature ref only when the exact // recorded tip is detached from every worktree AND contained in the verified // merge chain; deletes the REMOTE feature ref only under an exact old-value lease // AND only after a fresh probe proves no open child PR still targets it; and @@ -304,6 +304,13 @@ func finalizeCleanupDone(ctx context.Context, deps FinalizeDeps, cc *closeoutCon findings = append(findings, *f) } + // Leg 1b: repoint the merged PR's description backlink at the archived record + // when close-out left it pending. Best-effort and independent: a failure is a + // retryable pr-backlink-pending finding and never blocks the other legs. + if f := finalizeCleanupPRBacklinkRepair(ctx, deps, cc, ghRepo, number); f != nil { + findings = append(findings, *f) + } + // Leg 2: remove the feature checkout through the landed manifest-fact-driven // Cleanup. A blocked workspace or an unanswerable inspection retains the // workspace and — because the branch may still be checked out — skips the ref @@ -380,6 +387,24 @@ func finalizeCleanupResult(id int, disp string, removed []string, findings []Sta }) } +// finalizeCleanupPRBacklinkRepair re-runs the PR-body repoint idempotently for a +// done, archived change. In the normal flow (close-out already repointed it) it +// reads the PR and issues no edit; when close-out left the leg pending it lands +// the block-only edit. No editor wired runs nothing. +func finalizeCleanupPRBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, number int) *StatusFinding { + ed := prBodyEditor(deps) + if ed == nil { + return nil + } + id := int(cc.change.ID()) + interior, err := archivedBacklinkInterior(cc.eff, cc.change.Path(), cc.body, cc.link) + if err != nil { + f := cleanupWarning(ReasonPRBacklinkPending, "the archived backlink could not be rendered; retry cleanup") + return &f + } + return prBacklinkFinding(id, number, repointPRBacklink(ctx, ed, ghRepo, number, cc.change.Path(), interior)) +} + // finalizeCleanupBacklinkRepair re-runs the integration-ref backlink retarget // idempotently. In the normal flow (closeout already retargeted the blocks) it // is a clean no-op; when closeout left the leg pending it lands the exact diff --git a/internal/app/finalize_cleanup_integration_test.go b/internal/app/finalize_cleanup_integration_test.go index 0ff5c7b21..f0cfe9752 100644 --- a/internal/app/finalize_cleanup_integration_test.go +++ b/internal/app/finalize_cleanup_integration_test.go @@ -4,6 +4,7 @@ package app import ( "context" + "errors" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/workspace" @@ -445,3 +446,50 @@ func TestIntegrationFinalizeCleanupWorkspaceBlockedNamesPath(t *testing.T) { t.Fatalf("the blocking file must survive: %v", err) } } + +// TestIntegrationFinalizeCleanupPRBacklinkRetry proves cleanup is the retry for +// a PR backlink close-out left pending: a done change whose PR still names the +// active path is repointed; a failing edit makes the cleanup pending (retryable) +// without blocking the independent legs; a replay over a repointed PR issues no +// edit. +func TestIntegrationFinalizeCleanupPRBacklinkRetry(t *testing.T) { + requireRealGit(t) + + t.Run("repoints-a-pending-pr", func(t *testing.T) { + f := setupCloseoutFixture(t, planRepoModeDocket()) + head, mergeCommit := f.archiveClosed(t) + deps := f.cleanupDeps(f.mergedCleanupFake(head, mergeCommit), f.deps.Client, f.svc) + recPath := groomPath(f.id, f.slug) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(recPath, "Summary", "prose")}) + deps.PRBody = pr + + res := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if res.Disposition != CleanupDispCleaned { + t.Fatalf("cleanup = %q disp %q (%s) findings=%+v", res.Result, res.Disposition, res.Message, res.Findings) + } + if got := pr.bodies[closeoutPR]; strings.Contains(got, "`"+recPath+"`") || !strings.Contains(got, "docs/changes/archive/") { + t.Fatalf("cleanup did not repoint the PR backlink:\n%s", got) + } + replay := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if replay.Disposition == CleanupDispPending || pr.edits != 1 { + t.Fatalf("replay disp %q edits=%d, want clean with no new edit", replay.Disposition, pr.edits) + } + }) + + t.Run("failing-edit-is-pending-and-retryable", func(t *testing.T) { + f := setupCloseoutFixture(t, planRepoModeDocket()) + head, mergeCommit := f.archiveClosed(t) + deps := f.cleanupDeps(f.mergedCleanupFake(head, mergeCommit), f.deps.Client, f.svc) + pr := newFakePRBody(map[int]string{closeoutPR: artifactWithBacklink(groomPath(f.id, f.slug), "Summary", "prose")}) + pr.editErr = errors.New("gh: HTTP 502") + deps.PRBody = pr + + res := FinalizeCleanup(context.Background(), deps, f.repo.invocation, f.id) + if res.Disposition != CleanupDispPending || res.Reason != ReasonPRBacklinkPending { + t.Fatalf("cleanup = disp %q reason %q, want pending/%s", res.Disposition, res.Reason, ReasonPRBacklinkPending) + } + if f.remoteBranchPresent(t) { + t.Fatalf("a PR-body failure must not block the independent ref legs") + } + }) +} From 68f3b0b5728fbd12275a1a47e755b52a8152fde1 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:08:14 -0400 Subject: [PATCH 06/13] feat(maintenance): the full sweep assesses each done record's PR backlink from one batched read --- internal/app/maintenance.go | 50 ++++++-- internal/app/maintenance_assess.go | 60 +++++++-- internal/app/maintenance_assess_test.go | 114 ++++++++++++++++++ .../maintenance_traffic_integration_test.go | 48 +++++--- internal/app/sweep_prfacts.go | 48 ++++++-- internal/app/sweep_prfacts_test.go | 66 ++++++++++ 6 files changed, 347 insertions(+), 39 deletions(-) diff --git a/internal/app/maintenance.go b/internal/app/maintenance.go index 60639bbf3..909e1c511 100644 --- a/internal/app/maintenance.go +++ b/internal/app/maintenance.go @@ -335,10 +335,10 @@ func MaintenanceSweep(ctx context.Context, deps FinalizeDeps, repoDir string, sc }, assessHistorical: func(ctx context.Context, inv sweepInventory, pin StatusPin, historical []sweepWorkItem) ([]MaintenanceEntry, []sweepWorkItem) { // Gather the invocation-shared read-only inventories ONCE (one remote-heads - // advertisement, one worktree list) — and only when a done candidate needs - // them — then resolve every historical record locally, enqueuing only the + // advertisement, one worktree list, one batched PR-body read) — and only + // when a done candidate needs them — then resolve every historical record locally, enqueuing only the // records that warrant a fresh cleanup attempt. - shared := gatherSweepSharedFacts(ctx, deps, inv, historical) + shared := gatherSweepSharedFacts(ctx, deps, repoDir, inv, historical) return sweepAssessHistorical(ctx, deps, wdeps, inv, pin, shared, historical) }, syncIntegration: func(ctx context.Context) *SyncOutcome { @@ -530,14 +530,15 @@ func sweepBuildSnapshot(ctx context.Context, reader StatusReader, pin StatusPin, } // gatherSweepSharedFacts reads the invocation-shared read-only inventories the -// snapshot assessment reasons over: one complete remote-heads advertisement and -// one worktree list. It is LAZY — only a `done` candidate consumes the remote/ -// local-ref legs, so a historical worklist carrying only stacked-merged (retained) -// records fetches neither inventory (zero ListRemoteHeads calls). A failed read is +// snapshot assessment reasons over: one complete remote-heads advertisement, one +// worktree list, and one batched read of the done candidates' PR bodies. It is +// LAZY — only a `done` candidate consumes the remote/local-ref and PR-backlink +// legs, so a historical worklist carrying only stacked-merged (retained) records +// fetches no inventory (zero ListRemoteHeads or ProbePRSet calls). A failed read is // recorded as the leg's error (a failed shared inventory is unknown, never a clean // absence and never a fan-out into per-ref probes); a missing git client/repository // makes both inventories unknown rather than silently empty. -func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts { +func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir string, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts { needsShared := false for _, it := range historical { if c, out := inv.snap.Change(domain.ChangeID(it.id)); out == domain.LookupFound && c.Status() == domain.StatusDone { @@ -548,12 +549,41 @@ func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, inv sweepInv if !needsShared { return sweepSharedFacts{} } + var shared sweepSharedFacts + // One batched PR-body read over every done candidate's PR (≤25 per process) + // for the PR-backlink leg. A number the read could not resolve is unknown. + var numbers []int + for _, it := range historical { + c, out := inv.snap.Change(domain.ChangeID(it.id)) + if out != domain.LookupFound || c.Status() != domain.StatusDone { + continue + } + if n, ok := parsePRNumber(c.PR().Value); ok { + numbers = append(numbers, n) + } + } + shared.prBodiesGathered = true + shared.prBodies = map[int]string{} + shared.prBodiesUnknown = map[int]bool{} + if len(numbers) > 0 { + var bodies map[int]string + if deps.PRBatch != nil { + bodies = deps.PRBatch.ProbePRSet(ctx, repoDir, numbers).Bodies + } + for _, n := range numbers { + if b, ok := bodies[n]; ok { + shared.prBodies[n] = b + } else { + shared.prBodiesUnknown[n] = true + } + } + } repo := sweepAssessRepo(deps) if deps.Planning.Client == nil || repo.PrimaryWorktree == "" { err := fmt.Errorf("no git client or repository is wired to read the shared sweep inventories") - return sweepSharedFacts{remoteHeadsErr: err, worktreesErr: err} + shared.remoteHeadsErr, shared.worktreesErr = err, err + return shared } - var shared sweepSharedFacts if heads, err := deps.Planning.Client.ListRemoteHeads(ctx, repo, originRemote); err != nil { shared.remoteHeadsErr = err } else { diff --git a/internal/app/maintenance_assess.go b/internal/app/maintenance_assess.go index 43b46e25e..fe0d8baf7 100644 --- a/internal/app/maintenance_assess.go +++ b/internal/app/maintenance_assess.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "strings" "github.com/danielhanold/docket/internal/domain" @@ -34,7 +35,7 @@ import ( const ( // ReasonSweepSnapshotNoWork: every destructive leg is provably a no-op at the // pinned inventory — a cleaned tombstone, absent local/remote refs, and - // already-correct final backlinks. Nothing was dispatched. + // already-correct final and PR backlinks. Nothing was dispatched. ReasonSweepSnapshotNoWork = "snapshot-no-work" // ReasonSweepSnapshotRetained: a stacked-merged record is retained until its // stack root reaches the integration branch; a cleanup is never dispatched @@ -69,15 +70,22 @@ type sweepSharedFacts struct { remoteHeadsErr error // set ⇒ remote-ref absence is unprovable this invocation worktrees []gitcli.WorktreeInfo worktreesErr error // set ⇒ local-ref absence is unprovable this invocation + // prBodiesGathered is set when the invocation read its done candidates' PR + // bodies (one batched pass). When set, a number in neither prBodies nor + // prBodiesUnknown is still unknown — the leg never reads absence as no-work. + prBodiesGathered bool + prBodies map[int]string // PR number -> body (never logged) + prBodiesUnknown map[int]bool // PR numbers the batched read could not resolve } // The leg names the assessment reports in its diagnostic messages. They are // explanatory labels, not a parsed vocabulary. const ( - sweepLegBacklink = "backlink" - sweepLegWorkspace = "workspace" - sweepLegLocalRef = "local-ref" - sweepLegRemoteRef = "remote-ref" + sweepLegBacklink = "backlink" + sweepLegWorkspace = "workspace" + sweepLegLocalRef = "local-ref" + sweepLegRemoteRef = "remote-ref" + sweepLegPRBacklink = "pr-backlink" ) // sweepAssessHistorical resolves every full-scope done/stacked-merged candidate @@ -85,9 +93,10 @@ const ( // entries (Disposition skipped/blocked/unknown, EMPTY Operation — a pre-dispatch // observation, never a fabricated cleanup result) and the subset of candidates // that warrant one normal fresh cleanup attempt. It dispatches no metadata, PR, or -// remote-ref read of its own: the remote/worktree inventories arrive in shared, -// the backlink leg reads the pinned integration artifacts through the reader, and -// the workspace leg is a local-only inspection. +// remote-ref read of its own: the remote/worktree inventories and the batched +// PR-body inventory arrive in shared, the backlink leg reads the pinned +// integration artifacts through the reader, and the workspace leg is a local-only +// inspection. func sweepAssessHistorical(ctx context.Context, deps FinalizeDeps, wdeps WorkspaceDeps, inv sweepInventory, pin StatusPin, shared sweepSharedFacts, candidates []sweepWorkItem) (entries []MaintenanceEntry, actionable []sweepWorkItem) { @@ -153,6 +162,7 @@ func sweepAssessHistorical(ctx context.Context, deps FinalizeDeps, wdeps Workspa featureRef := gitcli.RefName(branchRefPrefix + branch) var a sweepLegAssessment sweepAssessBacklinkLeg(ctx, deps, pin, c, link, &a) + sweepAssessPRBacklinkLeg(shared, c, link, &a) sweepAssessWorkspaceLeg(ctx, wdeps, repo, target, &a) sweepAssessLocalRefLeg(shared, featureRef, &a) sweepAssessRemoteRefLeg(shared, featureRef, &a) @@ -274,6 +284,40 @@ func sweepBacklinkArtifactPaths(c domain.Change) []string { return out } +// sweepAssessPRBacklinkLeg resolves the PR-backlink leg from the shared, batched +// PR-body inventory: a body whose docket:backlink block does not name the +// archived record path has work (cleanup repoints it). A body already naming it, +// or with no block, is no-effect. An unread body or malformed markers is +// unresolved, never a clean no-op. Not gathered (an orchestration seam) is not +// assessed. +func sweepAssessPRBacklinkLeg(shared sweepSharedFacts, c domain.Change, link render.LinkContext, a *sweepLegAssessment) { + if !shared.prBodiesGathered { + return + } + n, ok := parsePRNumber(c.PR().Value) + if !ok { + return // identity already validated by the caller + } + body, read := shared.prBodies[n] + if !read || shared.prBodiesUnknown[n] { + a.markUnknown(sweepLegPRBacklink, fmt.Sprintf("PR #%d's body could not be read in the shared batch", n)) + return + } + block, err := render.BacklinkContent(c, link) + if err != nil { + a.markUnknown(sweepLegPRBacklink, "the record's archived backlink could not be rendered") + return + } + _, _, _, needs, err := planPRBacklinkRepoint([]byte(body), c.Path(), backlinkInterior(block)) + if err != nil { + a.markUnknown(sweepLegPRBacklink, fmt.Sprintf("PR #%d carries a malformed docket:backlink block", n)) + return + } + if needs { + a.markWork() + } +} + // sweepAssessWorkspaceLeg resolves the workspace leg from a local-only inspection // (workspace.Service.Inspect never fetches). A cleaned tombstone certifies the // checkout is already gone; a missing or foreign manifest does NOT certify clean — diff --git a/internal/app/maintenance_assess_test.go b/internal/app/maintenance_assess_test.go index 830a0db06..2f0357d45 100644 --- a/internal/app/maintenance_assess_test.go +++ b/internal/app/maintenance_assess_test.go @@ -3,12 +3,15 @@ package app import ( "context" "errors" + "fmt" + "strconv" "strings" "testing" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/render" + "github.com/danielhanold/docket/internal/repository" "github.com/danielhanold/docket/internal/workspace" ) @@ -418,3 +421,114 @@ func TestAssessUnknownLegNamedInMessageEvenWhenBlocked(t *testing.T) { t.Fatalf("the unknown remote-ref leg must stay named even when the workspace is blocked, got %q", e.Message) } } + +// prShared builds shared facts whose PR-body inventory was gathered, carrying +// the given bodies (and nothing unknown). +func prShared(bodies map[int]string) sweepSharedFacts { + return sweepSharedFacts{ + remoteHeads: map[gitcli.RefName]gitcli.ObjectID{}, + prBodiesGathered: true, + prBodies: bodies, + prBodiesUnknown: map[int]bool{}, + } +} + +// assessArchivedDoneBlob is assessDoneBlob relocated to the archive, as close-out +// leaves every done record: the PR-backlink leg keys on the record's own path, so +// a stale body (naming the active path) is distinguishable from a repointed one. +func assessArchivedDoneBlob(id int, slug string) StatusBlob { + b := assessDoneBlob(id, slug, "") + b.Location = repository.LocationArchive + b.Path = fmt.Sprintf("docs/changes/archive/2026-01-03-%04d-%s.md", id, slug) + return b +} + +func prNumberOf(t *testing.T, f assessFixture, id int) int { + t.Helper() + c, _ := f.inv.snap.Change(domain.ChangeID(id)) + n, ok := parsePRNumber(c.PR().Value) + if !ok { + t.Fatalf("record %d carries no parsable PR", id) + } + return n +} + +// TestAssessStalePRBacklinkIsActionable: a done record whose PR body still names +// the active path has work — it is dispatched to cleanup, which repoints it. +func TestAssessStalePRBacklinkIsActionable(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessArchivedDoneBlob(41, "archived")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{n: prBodyWithActiveBacklink("docs/changes/active/0041-archived.md", "prose")}) + _, actionable := f.assess(t, cleanWS(), shared, 41) + if !actionableHas(actionable, 41) { + t.Fatalf("a stale PR backlink must make the record actionable; actionable=%v", actionable) + } +} + +// TestAssessCorrectPRBacklinkIsNoWork: a PR body already naming the archive path +// adds no work. +func TestAssessCorrectPRBacklinkIsNoWork(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessArchivedDoneBlob(41, "archived")}, nil) + n := prNumberOf(t, f, 41) + body := "\n" + assessInterior(t, f, 41) + "\n\n\nprose\n" + entries, actionable := f.assess(t, cleanWS(), prShared(map[int]string{n: body}), 41) + if len(actionable) != 0 { + t.Fatalf("an already-correct PR backlink must add no work; actionable=%v", actionable) + } + assertEntry(t, findEntry(entries, 41), SweepDispSkipped, ReasonSweepSnapshotNoWork) +} + +// TestAssessUnreadPRBodyIsUnknownNeverNoWork: a gathered inventory missing this +// PR (a failed batch or a Found=false slot) is unknown, never certified no-work. +func TestAssessUnreadPRBodyIsUnknownNeverNoWork(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessArchivedDoneBlob(41, "archived")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{}) + shared.prBodiesUnknown[n] = true + entries, actionable := f.assess(t, cleanWS(), shared, 41) + if len(actionable) != 0 { + t.Fatalf("an unread PR body must not dispatch; actionable=%v", actionable) + } + e := findEntry(entries, 41) + assertEntry(t, e, SweepDispUnknown, ReasonSweepSnapshotUnknown) + if !strings.Contains(e.Message, sweepLegPRBacklink) { + t.Fatalf("the unknown leg must be named; msg=%q", e.Message) + } +} + +// TestAssessPRBacklinkMalformedIsUnknown: malformed backlink markers in the PR +// body are unknown, never no-work and never work. +func TestAssessPRBacklinkMalformedIsUnknown(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessArchivedDoneBlob(41, "archived")}, nil) + n := prNumberOf(t, f, 41) + shared := prShared(map[int]string{n: "\ndangling\n"}) + entries, actionable := f.assess(t, cleanWS(), shared, 41) + if len(actionable) != 0 { + t.Fatalf("malformed markers must not dispatch; actionable=%v", actionable) + } + assertEntry(t, findEntry(entries, 41), SweepDispUnknown, ReasonSweepSnapshotUnknown) +} + +// TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown: every done candidate's PR +// number lands in exactly one of prBodies or prBodiesUnknown, so no done record +// is silently unassessed; one batched read serves them all. +func TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "a", ""), assessDoneBlob(42, "b", "")}, nil) + n41, n42 := prNumberOf(t, f, 41), prNumberOf(t, f, 42) + batch := &fakeSweepBatchReader{result: SweepPRSetResult{ + Facts: map[int]domain.PRFacts{n41: {Number: strconv.Itoa(n41), State: "merged"}}, + Bodies: map[int]string{n41: "body"}, + }} + deps := FinalizeDeps{Planning: PlanningDeps{Reader: f.reader, Clock: testClock()}, PRBatch: batch} + items := []sweepWorkItem{{id: 41, kind: sweepKindCleanup}, {id: 42, kind: sweepKindCleanup}} + shared := gatherSweepSharedFacts(context.Background(), deps, "repo", f.inv, items) + if !shared.prBodiesGathered || batch.calls != 1 { + t.Fatalf("gathered=%v calls=%d, want one batched read", shared.prBodiesGathered, batch.calls) + } + if shared.prBodies[n41] != "body" || shared.prBodiesUnknown[n41] { + t.Errorf("PR #%d should be read", n41) + } + if _, ok := shared.prBodies[n42]; ok || !shared.prBodiesUnknown[n42] { + t.Errorf("PR #%d was not returned, so it must be unknown", n42) + } +} diff --git a/internal/app/maintenance_traffic_integration_test.go b/internal/app/maintenance_traffic_integration_test.go index c0e8fa4f6..4e7ac3ac5 100644 --- a/internal/app/maintenance_traffic_integration_test.go +++ b/internal/app/maintenance_traffic_integration_test.go @@ -314,9 +314,11 @@ func TestIntegrationSweepDiscoveryTrafficAccounting(t *testing.T) { if got := countPrefix(ghl, "repo view"); got > 1 { t.Errorf("GitHub identity resolved %d times, want at most 1", got) } - // One batched PR read per 25 unique numbers. - if got := countPrefix(ghl, "api graphql"); got != 2 { - t.Errorf("api graphql invocations = %d, want 2 (ceil(30/25)); no per-PR fallback\n%s", got, strings.Join(ghl, "\n")) + // One batched PR read per 25 unique numbers: ceil(30/25) = 2 for the finalize + // population, plus ONE batched PR-body read over the three done records for the + // PR-backlink leg — never a per-PR fallback. + if got := countPrefix(ghl, "api graphql"); got != 3 { + t.Errorf("api graphql invocations = %d, want 3 (ceil(30/25) population + ceil(3/25) PR-body); no per-PR fallback\n%s", got, strings.Join(ghl, "\n")) } // At most one shared inventory advertisement — never one per historical record. if got := countMatching(gl, "ls-remote", "--heads"); got != 1 { @@ -363,11 +365,12 @@ func TestIntegrationSweepDiscoveryTrafficAccounting(t *testing.T) { } } -// TestIntegrationSweepZeroPRsNoGitHubTraffic: a corpus whose only records are -// final (done) — hence outside the PR-bearing finalize population — issues ZERO -// gh processes. An empty number set never resolves the identity and never opens a -// batch. -func TestIntegrationSweepZeroPRsNoGitHubTraffic(t *testing.T) { +// TestIntegrationSweepDoneOnlyCorpusReadsOnlyPRBodies: a corpus whose only +// records are final (done) — hence outside the PR-bearing finalize population — +// opens no finalize-population batch. Its only GitHub traffic is the PR-backlink +// leg's read: one identity resolution and ONE batched PR-body read naming exactly +// the done records' numbers. +func TestIntegrationSweepDoneOnlyCorpusReadsOnlyPRBodies(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0041-a.md": trafficDoneRecord(41, "a"), @@ -379,8 +382,14 @@ func TestIntegrationSweepZeroPRsNoGitHubTraffic(t *testing.T) { res := MaintenanceSweep(context.Background(), deps, r.invocation, SweepScopeFull) - if got := ghLogLines(t, ghLog); len(got) != 0 { - t.Fatalf("zero PR numbers must spawn zero gh processes, got %d:\n%s", len(got), strings.Join(got, "\n")) + ghl := ghLogLines(t, ghLog) + if len(ghl) != 2 || countPrefix(ghl, "repo view") != 1 || countPrefix(ghl, "api graphql") != 1 { + t.Fatalf("a done-only corpus must spawn exactly one identity resolution and one PR-body batch, got %d:\n%s", len(ghl), strings.Join(ghl, "\n")) + } + for _, want := range []string{"pullRequest(number: 41)", "pullRequest(number: 42)"} { + if !strings.Contains(strings.Join(ghl, "\n"), want) { + t.Errorf("the PR-body batch must read %s:\n%s", want, strings.Join(ghl, "\n")) + } } if len(res.Entries) != 2 { t.Fatalf("want two historical entries, got %d", len(res.Entries)) @@ -449,10 +458,13 @@ func sweepRemoteCounts(t *testing.T, gitLog, ghLog string) remoteCounts { // TestIntegrationSweepAssessmentTrafficConstantAcrossHistory is the scaling proof: // growing the final (done) population 1 -> 25 -> 250 at fixed active/pending -// work must NOT change any remote-call count. The assessment reasons from ONE -// shared advertisement and ONE worktree list plus local per-record inspection, so -// its remote footprint is history-independent; a reintroduced per-history refresh -// or per-ref probe would make one of these counts grow with the population. +// work must NOT change any git remote-call count or the GitHub identity count. +// The assessment reasons from ONE shared advertisement and ONE worktree list plus +// local per-record inspection, so its git footprint is history-independent; a +// reintroduced per-history refresh or per-ref probe would make one of these counts +// grow with the population. The one history-proportional read is the PR-backlink +// leg's batched PR-body read, pinned to exactly ceil(history/25) GraphQL +// processes — batched, never one per record. func TestIntegrationSweepAssessmentTrafficConstantAcrossHistory(t *testing.T) { requireRealGit(t) measure := func(t *testing.T, history int) (remoteCounts, MaintenanceResult) { @@ -480,8 +492,16 @@ func TestIntegrationSweepAssessmentTrafficConstantAcrossHistory(t *testing.T) { if base.sharedHeads != 1 { t.Fatalf("history=1 shared-inventory count = %d, want 1", base.sharedHeads) } + if base.graphql != 1 { + t.Fatalf("history=1 PR-body batches = %d, want 1", base.graphql) + } for _, history := range []int{25, 250} { got, res := measure(t, history) + wantBatches := (history + sweepPRBatchCap - 1) / sweepPRBatchCap + if got.graphql != wantBatches { + t.Errorf("history=%d PR-body batches = %d, want ceil(%d/25) = %d (batched, never per record)", history, got.graphql, history, wantBatches) + } + got.graphql = base.graphql // pinned above; every other count is history-independent if got != base { t.Errorf("history=%d amplified remote work: got %+v, base %+v", history, got, base) } diff --git a/internal/app/sweep_prfacts.go b/internal/app/sweep_prfacts.go index 09e77b1fe..bc2acd072 100644 --- a/internal/app/sweep_prfacts.go +++ b/internal/app/sweep_prfacts.go @@ -5,6 +5,7 @@ import ( "fmt" "sort" "strconv" + "sync" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/githubcli" @@ -35,6 +36,10 @@ type SweepPRBatchReader interface { type SweepPRSetResult struct { Facts map[int]domain.PRFacts Failures []SweepPRBatchFailure // one per failed batch (incl. identity resolution: one failure covering all numbers) + // Bodies carries each Found slot's PR body, for the sweep's PR-backlink leg. + // It is never logged or reported. A number absent here is unread (a failed + // batch or an unresolved slot). + Bodies map[int]string } // SweepPRBatchFailure names the numbers a single failed batch (or a failed @@ -61,11 +66,38 @@ type sweepGitHub interface { const sweepPRBatchCap = 25 // sweepPRBatchReader is the production SweepPRBatchReader. It resolves the -// repository once per invocation (memoizing a resolution failure as one Failure -// covering every number — no per-consumer retry), then reads the deduped, sorted -// numbers in ≤25-number batches, one ViewPullRequestsBatch process per batch. +// repository once per reader and repository directory — the CLI builds one reader +// per process, and a sweep reads twice (the finalize population's facts, then the +// done candidates' PR bodies) — memoizing a resolution failure too, reported as +// one Failure covering each read's numbers (no per-consumer retry). It then reads +// the deduped, sorted numbers in ≤25-number batches, one ViewPullRequestsBatch +// process per batch. type sweepPRBatchReader struct { gh sweepGitHub + + mu sync.Mutex + identities map[string]sweepIdentity // repoDir -> memoized resolution +} + +// sweepIdentity is one memoized repository resolution, success or failure. +type sweepIdentity struct { + repo githubcli.Repository + err error +} + +// identity resolves repoDir's repository once per reader, memoizing the outcome. +func (r *sweepPRBatchReader) identity(ctx context.Context, repoDir string) (githubcli.Repository, error) { + r.mu.Lock() + defer r.mu.Unlock() + if id, ok := r.identities[repoDir]; ok { + return id.repo, id.err + } + repo, err := r.gh.DiscoverRepository(ctx, repoDir) + if r.identities == nil { + r.identities = make(map[string]sweepIdentity) + } + r.identities[repoDir] = sweepIdentity{repo: repo, err: err} + return repo, err } // NewSweepPRBatchReader builds the production batched PR reader over a GitHub @@ -83,14 +115,15 @@ func NewSweepPRBatchReader(gh *githubcli.Client) SweepPRBatchReader { func (r *sweepPRBatchReader) ProbePRSet(ctx context.Context, repoDir string, numbers []int) SweepPRSetResult { sorted := sweepDedupeSortAsc(numbers) facts := make(map[int]domain.PRFacts) + bodies := make(map[int]string) if len(sorted) == 0 { - return SweepPRSetResult{Facts: facts} + return SweepPRSetResult{Facts: facts, Bodies: bodies} } // One shared identity for the whole invocation. A resolution failure is // memoized as a single Failure over every number — no consumer re-resolves. - repo, err := r.gh.DiscoverRepository(ctx, repoDir) + repo, err := r.identity(ctx, repoDir) if err != nil { - return SweepPRSetResult{Facts: facts, Failures: []SweepPRBatchFailure{{Numbers: sorted, Message: err.Error()}}} + return SweepPRSetResult{Facts: facts, Bodies: bodies, Failures: []SweepPRBatchFailure{{Numbers: sorted, Message: err.Error()}}} } var failures []SweepPRBatchFailure for _, chunk := range sweepChunkInts(sorted, sweepPRBatchCap) { @@ -106,9 +139,10 @@ func (r *sweepPRBatchReader) ProbePRSet(ctx context.Context, repoDir string, num continue // unknown: omitted, never a fabricated absence } facts[n] = sweepBatchResultToFacts(n, br) + bodies[n] = br.PR.Body } } - return SweepPRSetResult{Facts: facts, Failures: failures} + return SweepPRSetResult{Facts: facts, Bodies: bodies, Failures: failures} } // sweepBatchResultToFacts maps one resolved batch slot to domain.PRFacts with the diff --git a/internal/app/sweep_prfacts_test.go b/internal/app/sweep_prfacts_test.go index 136cca63f..b23819cf8 100644 --- a/internal/app/sweep_prfacts_test.go +++ b/internal/app/sweep_prfacts_test.go @@ -316,3 +316,69 @@ func sweepTestSnapshot(t *testing.T, blobs []StatusBlob) domain.Snapshot { } return build.Snapshot } + +// TestSweepPRSetCarriesBodiesForFoundSlotsOnly: ProbePRSet returns each Found +// slot's body and nothing for an unresolved slot (Found=false or a failed batch). +func TestSweepPRSetCarriesBodiesForFoundSlotsOnly(t *testing.T) { + gh := &countingSweepGitHub{repo: sweepTestRepo(), results: map[int]githubcli.BatchPRResult{ + 1: {Found: true, PR: githubcli.PullRequest{Number: 1, State: githubcli.StateMerged, Body: "body one"}}, + }} + r := &sweepPRBatchReader{gh: gh} + res := r.ProbePRSet(context.Background(), "repo", []int{1, 2}) + if res.Bodies[1] != "body one" { + t.Errorf("found PR #1 carries body %q, want %q", res.Bodies[1], "body one") + } + if _, ok := res.Bodies[2]; ok { + t.Errorf("unresolved PR #2 (Found=false) must carry no body") + } + for n, f := range res.Facts { + if _, ok := res.Bodies[n]; !ok { + t.Errorf("found PR #%d (%+v) carries no body", n, f) + } + } + for n := range res.Bodies { + if _, ok := res.Facts[n]; !ok { + t.Errorf("PR #%d has a body but no facts (an unresolved slot must carry neither)", n) + } + } + + // A failed batch and a failed identity resolution carry no bodies, and the + // map is non-nil on every return path. + for _, gh := range []*countingSweepGitHub{ + {repo: sweepTestRepo(), failBatchFor: map[int]bool{1: true}}, + {repo: sweepTestRepo(), discoverErr: fmt.Errorf("no identity")}, + } { + res := (&sweepPRBatchReader{gh: gh}).ProbePRSet(context.Background(), "repo", []int{1}) + if res.Bodies == nil || len(res.Bodies) != 0 { + t.Errorf("an unresolved read must return an empty, non-nil Bodies; got %v", res.Bodies) + } + } + if res := (&sweepPRBatchReader{gh: &countingSweepGitHub{repo: sweepTestRepo()}}).ProbePRSet(context.Background(), "repo", nil); res.Bodies == nil { + t.Errorf("an empty request must return a non-nil Bodies") + } +} + +// TestSweepPRSetResolvesIdentityOncePerReader: the sweep issues two batched reads +// per invocation (the finalize population's facts, then the done candidates' PR +// bodies), so the reader resolves the repository once and reuses it — success or +// failure — rather than once per ProbePRSet call. +func TestSweepPRSetResolvesIdentityOncePerReader(t *testing.T) { + gh := &countingSweepGitHub{repo: sweepTestRepo()} + r := &sweepPRBatchReader{gh: gh} + r.ProbePRSet(context.Background(), "repo", []int{1}) + r.ProbePRSet(context.Background(), "repo", []int{2}) + if gh.discovers != 1 { + t.Errorf("identity resolved %d times across two reads, want exactly 1", gh.discovers) + } + + failing := &countingSweepGitHub{discoverErr: fmt.Errorf("no identity")} + fr := &sweepPRBatchReader{gh: failing} + first := fr.ProbePRSet(context.Background(), "repo", []int{1}) + second := fr.ProbePRSet(context.Background(), "repo", []int{2}) + if failing.discovers != 1 { + t.Errorf("a failed identity resolved %d times across two reads, want exactly 1 (memoized)", failing.discovers) + } + if len(first.Failures) != 1 || len(second.Failures) != 1 || !reflect.DeepEqual(second.Failures[0].Numbers, []int{2}) { + t.Errorf("each read must report its own numbers as one failure; first=%+v second=%+v", first.Failures, second.Failures) + } +} From 0b53434fed42057ce1aa9ceaa15ffbf82c7afac5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:12:02 -0400 Subject: [PATCH 07/13] feat(repository): repair --pr-backlinks repoints merged PRs' dead change backlinks after a preview --- internal/app/repository_facts.go | 6 +- internal/app/repository_repair.go | 83 ++++-- .../app/repository_repair_integration_test.go | 193 ++++++++++++ internal/app/repository_repair_prbacklinks.go | 277 ++++++++++++++++++ internal/cli/repository.go | 32 +- internal/cli/repository_test.go | 63 +++- 6 files changed, 614 insertions(+), 40 deletions(-) create mode 100644 internal/app/repository_repair_prbacklinks.go diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index 5fd4a82ec..a9d541dea 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -43,7 +43,11 @@ func setupRemote() gitcli.RemoteName { return originRemote } type SetupDeps struct { Git *gitcli.Client RepoDir string // invocation dir; Discover resolves the canonical primary - hooks setupHooks + // GitHub is set ONLY by `repository repair --pr-backlinks` (the one + // repository command that reads and edits merged PR bodies). Every other + // repository command leaves it nil and never consults it. + GitHub RepairGitHub + hooks setupHooks } // setupHooks is the generalized interruption seam. Each hook, when non-nil, is diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index 6dc5877b0..0f53266a2 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -47,6 +47,9 @@ const repairConfirmationRequiredState = "confirmation-required" type RepairOptions struct { Authorized bool ExpectedSource string + // PRBacklinks selects the PR-backlink repair instead of the metadata + // repairs; it requires SetupDeps.GitHub. + PRBacklinks bool } // RepositoryRepairResult is the protocol-v1 document `repository repair` @@ -65,6 +68,8 @@ type RepositoryRepairResult struct { RepairedFiles []string `json:"repaired_files,omitempty"` ManualReview []string `json:"manual_review,omitempty"` PendingLocal []string `json:"pending_local,omitempty"` + // PRBacklinks is the --pr-backlinks preview or report, one row per PR. + PRBacklinks []PRBacklinkRepair `json:"pr_backlinks,omitempty"` // Findings carries a resolver diagnosis lifted from a gather failure. Findings []reposetup.Finding `json:"findings,omitempty"` human string @@ -104,60 +109,82 @@ type repositoryRepairPlan struct { // check` reports on an already-migrated repository, under the two-pass // authorization model. func RunRepositoryRepair(ctx context.Context, d SetupDeps, o RepairOptions) RepositoryRepairResult { + if o.PRBacklinks { + return runPRBacklinkRepair(ctx, d, o) + } + sc, metadataTip, refusal := repairPreflight(ctx, d) + if refusal != nil { + return *refusal + } + corpus, err := readCheckCorpus(ctx, d.Git, sc) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "reading the metadata corpus", err) + } + plan, err := planRepositoryRepair(sc, corpus) + if err != nil { + return repairInternalFailure(reposetup.StateHealthy, "planning the repairs", err) + } + if len(plan.files) == 0 { + return repairNoOp(metadataTip, plan.manual) + } + if !o.Authorized { + return repairConfirmationRequired(sc, metadataTip, plan) + } + // Decide-and-act on the same copy: an authorized re-invocation acts only on + // the exact metadata revision its preview showed. + if migrateSourceMoved(o.ExpectedSource, metadataTip) { + return repairContended(metadataTip, o.ExpectedSource) + } + return executeRepositoryRepair(ctx, d.Git, sc, metadataTip, plan) +} + +// repairPreflight runs the shared gather and topology routing every repair mode +// starts with and returns the setup context and the pinned metadata tip, or the +// refusal to return verbatim. +func repairPreflight(ctx context.Context, d SetupDeps) (setupContext, string, *RepositoryRepairResult) { facts, sc, err := GatherSetupFacts(ctx, d, true) if err != nil { - return repairFromMigrateResult(migrateGatherFailure(err)) + r := repairFromMigrateResult(migrateGatherFailure(err)) + return setupContext{}, "", &r } phase, refusal := migrateRoute(ctx, d.Git, facts, &sc) if refusal != nil { if refusal.RepositoryState == string(reposetup.StateFresh) { - return repairRefusal(reposetup.StateFresh, + r := repairRefusal(reposetup.StateFresh, "the repository has no docket metadata branch; run `docket repository init` to create it") + return setupContext{}, "", &r } - return repairFromMigrateResult(*refusal) + r := repairFromMigrateResult(*refusal) + return setupContext{}, "", &r } switch phase { case phaseAlreadyMigrated: // proceed case phaseLegacyFull: - return repairRefusal(reposetup.StateLegacy, + r := repairRefusal(reposetup.StateLegacy, "the repository has a legacy single-branch planning surface; run `docket repository migrate` to convert it, then re-run `docket repository repair`") + return setupContext{}, "", &r case phaseResumePrune: - return repairRefusal(reposetup.StatePartial, + r := repairRefusal(reposetup.StatePartial, "an interrupted migration still has a live planning surface on the integration branch; run `docket repository migrate` to finish it, then re-run `docket repository repair`") + return setupContext{}, "", &r case phaseResumeLocal: - return repairRefusal(reposetup.StateNeedsReview, + r := repairRefusal(reposetup.StateNeedsReview, "the local .docket metadata attachment is incomplete; run `docket repository migrate` to finish attaching it, then re-run `docket repository repair`") + return setupContext{}, "", &r default: - return repairInternalFailure(reposetup.StateUnknown, "routing the repository topology", + r := repairInternalFailure(reposetup.StateUnknown, "routing the repository topology", fmt.Errorf("unexpected topology phase %d", phase)) + return setupContext{}, "", &r } metadataTip := sc.metadataTip if metadataTip == "" { - return repairExternalFailure(reposetup.StateHealthy, "pinning the metadata revision", + r := repairExternalFailure(reposetup.StateHealthy, "pinning the metadata revision", errors.New("no authoritative metadata tip is available")) + return setupContext{}, "", &r } - corpus, err := readCheckCorpus(ctx, d.Git, sc) - if err != nil { - return repairExternalFailure(reposetup.StateHealthy, "reading the metadata corpus", err) - } - plan, err := planRepositoryRepair(sc, corpus) - if err != nil { - return repairInternalFailure(reposetup.StateHealthy, "planning the repairs", err) - } - if len(plan.files) == 0 { - return repairNoOp(metadataTip, plan.manual) - } - if !o.Authorized { - return repairConfirmationRequired(sc, metadataTip, plan) - } - // Decide-and-act on the same copy: an authorized re-invocation acts only on - // the exact metadata revision its preview showed. - if migrateSourceMoved(o.ExpectedSource, metadataTip) { - return repairContended(metadataTip, o.ExpectedSource) - } - return executeRepositoryRepair(ctx, d.Git, sc, metadataTip, plan) + return sc, metadataTip, nil } // planRepositoryRepair is the pure repair decision. Frontmatter repairs are diff --git a/internal/app/repository_repair_integration_test.go b/internal/app/repository_repair_integration_test.go index 01b38827e..0dc356342 100644 --- a/internal/app/repository_repair_integration_test.go +++ b/internal/app/repository_repair_integration_test.go @@ -4,11 +4,13 @@ package app import ( "context" + "errors" "path/filepath" "strconv" "strings" "testing" + "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/reposetup" ) @@ -319,3 +321,194 @@ func TestIntegrationRepoRepairMalformedMarkerIsManualReview(t *testing.T) { t.Errorf("malformed record changed:\n%s", got) } } + +// fakeRepairGitHub is a scriptable RepairGitHub: bodies by PR number, a batch +// failure switch, per-number edit outcomes, and call counters. +type fakeRepairGitHub struct { + bodies map[int]string + failBatch bool + editOut map[int]githubcli.BodyEditOutcome + batchCalls int + edits int + t *testing.T + forbidAll bool // any call fails the test (routine-commands guard) +} + +func (f *fakeRepairGitHub) rev(n int) string { + return "rev:" + strconv.Itoa(len(f.bodies[n])) + ":" + f.bodies[n][:min(8, len(f.bodies[n]))] +} + +func (f *fakeRepairGitHub) DiscoverRepository(context.Context, string) (githubcli.Repository, error) { + if f.forbidAll { + f.t.Errorf("DiscoverRepository called by a command that must not touch GitHub") + return githubcli.Repository{}, errors.New("forbidden") + } + return githubcli.Repository{Host: "github.com", Owner: "acme", Name: "widget"}, nil +} + +func (f *fakeRepairGitHub) ViewPullRequestsBatch(_ context.Context, _ githubcli.Repository, numbers []int) (map[int]githubcli.BatchPRResult, error) { + f.batchCalls++ + if f.forbidAll { + f.t.Errorf("ViewPullRequestsBatch called by a command that must not read PR bodies") + return nil, errors.New("forbidden") + } + if f.failBatch { + return nil, errors.New("gh api graphql failed") + } + out := map[int]githubcli.BatchPRResult{} + for _, n := range numbers { + b, ok := f.bodies[n] + if !ok { + out[n] = githubcli.BatchPRResult{Found: false} + continue + } + out[n] = githubcli.BatchPRResult{Found: true, PR: githubcli.PullRequest{Number: n, State: githubcli.StateMerged, Body: b, Revision: f.rev(n)}} + } + return out, nil +} + +func (f *fakeRepairGitHub) EditPullRequestBody(_ context.Context, _ githubcli.Repository, n int, rev, body string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) { + f.edits++ + if f.forbidAll { + f.t.Errorf("EditPullRequestBody called by a command that must not touch GitHub") + return githubcli.BodyUnknown, githubcli.PullRequest{}, errors.New("forbidden") + } + if o, ok := f.editOut[n]; ok { + return o, githubcli.PullRequest{}, nil + } + if rev != f.rev(n) { + return githubcli.BodyContended, githubcli.PullRequest{}, nil + } + f.bodies[n] = body + return githubcli.BodyEdited, githubcli.PullRequest{Number: n, Body: body}, nil +} + +const ( + prRepairPath = "docs/changes/archive/2026-08-29-0363-old-change.md" + prRepairActive = "docs/changes/active/0363-old-change.md" + prRepairOKPath = "docs/changes/archive/2026-08-30-0364-fixed-change.md" +) + +// prArchivedRecord is a minimal valid archived done record carrying a PR URL. +func prArchivedRecord(id int, slug string, pr int) string { + return "---\nid: " + strconv.Itoa(id) + "\nslug: " + slug + "\nstatus: done\ntitle: Change " + slug + + "\ntype: feature\npr: 'https://github.com/acme/widget/pull/" + strconv.Itoa(pr) + "'\n---\n\nBody for " + slug + ".\n" +} + +func prBodyNaming(path string) string { + return "\n" + + "> ↩ **Change — x** — `" + path + "`\n\n\nAuthored PR prose.\n" +} + +// publishPRBacklinkRecords publishes one archived record whose PR still names +// the active path (dead) and one whose PR already names its archive path. +func (r *initRepo) publishPRBacklinkRecords(t *testing.T) { + t.Helper() + dotDocket := filepath.Join(r.invocation, ".docket") + writeRepoFile(t, dotDocket, prRepairPath, prArchivedRecord(363, "old-change", 250)) + writeRepoFile(t, dotDocket, prRepairOKPath, prArchivedRecord(364, "fixed-change", 251)) + runGit(t, dotDocket, "add", "--", prRepairPath, prRepairOKPath) + runGit(t, dotDocket, "commit", "-q", "-m", "publish archived PR-bearing records") + runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) +} + +func (r *initRepo) runPRRepair(t *testing.T, gh RepairGitHub, o RepairOptions) RepositoryRepairResult { + t.Helper() + o.PRBacklinks = true + return RunRepositoryRepair(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation, GitHub: gh}, o) +} + +// TestIntegrationRepoRepairPRBacklinksPreviewApplyThenNothing covers spec +// acceptance 3: the preview lists exactly the dead-link PR, --yes fixes it, and +// a second run reports nothing to do. +func TestIntegrationRepoRepairPRBacklinksPreviewApplyThenNothing(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + gh := &fakeRepairGitHub{t: t, bodies: map[int]string{250: prBodyNaming(prRepairActive), 251: prBodyNaming(prRepairOKPath)}} + + preview := r.runPRRepair(t, gh, RepairOptions{}) + if !preview.ConfirmationRequired() || len(preview.PRBacklinks) != 1 { + t.Fatalf("preview = %q state %q entries %+v, want one planned PR", preview.Result, preview.RepositoryState, preview.PRBacklinks) + } + e := preview.PRBacklinks[0] + if e.ID != 363 || e.PR != 250 || e.Outcome != PRBacklinkRepairPlanned || + !strings.Contains(e.Current, prRepairActive) || !strings.Contains(e.Corrected, prRepairPath) { + t.Fatalf("preview entry = %+v", e) + } + if gh.edits != 0 { + t.Fatalf("a preview must edit nothing; edits=%d", gh.edits) + } + + applied := r.runPRRepair(t, gh, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + if applied.Result != ResultApplied || len(applied.PRBacklinks) != 1 || applied.PRBacklinks[0].Outcome != PRBacklinkRepairRepointed { + t.Fatalf("apply = %q %+v", applied.Result, applied.PRBacklinks) + } + if !strings.Contains(gh.bodies[250], prRepairPath) || !strings.HasSuffix(gh.bodies[250], "\n\nAuthored PR prose.\n") { + t.Fatalf("PR #250 not repointed with prose intact:\n%s", gh.bodies[250]) + } + if gh.bodies[251] != prBodyNaming(prRepairOKPath) { + t.Fatalf("an already-correct PR was edited") + } + + again := r.runPRRepair(t, gh, RepairOptions{}) + if again.Result != ResultNoOp || len(again.PRBacklinks) != 0 { + t.Fatalf("second run = %q %+v, want nothing to do", again.Result, again.PRBacklinks) + } +} + +// TestIntegrationRepoRepairPRBacklinksSkipsUnreadable: an unreadable PR (a +// Found=false slot) and a malformed block are reported and skipped; a contended +// edit is reported; none aborts the batch. +func TestIntegrationRepoRepairPRBacklinksSkipsUnreadable(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + dotDocket := filepath.Join(r.invocation, ".docket") + third := "docs/changes/archive/2026-08-31-0365-third-change.md" + writeRepoFile(t, dotDocket, third, prArchivedRecord(365, "third-change", 252)) + runGit(t, dotDocket, "add", "--", third) + runGit(t, dotDocket, "commit", "-q", "-m", "third") + runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + + gh := &fakeRepairGitHub{t: t, bodies: map[int]string{ + 250: prBodyNaming(prRepairActive), + 252: "\ndangling\n", + }} // 251 absent: Found=false + preview := r.runPRRepair(t, gh, RepairOptions{}) + byPR := map[int]PRBacklinkRepair{} + for _, e := range preview.PRBacklinks { + byPR[e.PR] = e + } + if byPR[250].Outcome != PRBacklinkRepairPlanned || byPR[251].Outcome != PRBacklinkRepairUnreadable || byPR[252].Outcome != PRBacklinkRepairUnreadable { + t.Fatalf("entries = %+v", preview.PRBacklinks) + } + + gh.editOut = map[int]githubcli.BodyEditOutcome{250: githubcli.BodyContended} + applied := r.runPRRepair(t, gh, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + for _, e := range applied.PRBacklinks { + if e.PR == 250 && e.Outcome != PRBacklinkRepairContended { + t.Fatalf("PR #250 outcome = %q, want contended", e.Outcome) + } + } + if gh.edits != 1 { + t.Fatalf("only the planned PR may be edited; edits=%d", gh.edits) + } +} + +// TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies pins spec acceptance 4: +// with a GitHub fake that fails on ANY call wired into SetupDeps, routine +// `repository repair` (preview and --yes), `check`, and `prepare` never call it. +func TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies(t *testing.T) { + r := newHealthyRepo(t) + r.publishPRBacklinkRecords(t) + r.publishHealthyDrift(t) + gh := &fakeRepairGitHub{t: t, forbidAll: true} + d := SetupDeps{Git: newGitClient(t), RepoDir: r.invocation, GitHub: gh} + + _ = RunRepositoryCheck(context.Background(), d) + _ = RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) + preview := RunRepositoryRepair(context.Background(), d, RepairOptions{}) + _ = RunRepositoryRepair(context.Background(), d, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRev()}) + if gh.batchCalls != 0 || gh.edits != 0 { + t.Fatalf("routine commands touched GitHub: batch=%d edits=%d", gh.batchCalls, gh.edits) + } +} diff --git a/internal/app/repository_repair_prbacklinks.go b/internal/app/repository_repair_prbacklinks.go new file mode 100644 index 000000000..091ea2b74 --- /dev/null +++ b/internal/app/repository_repair_prbacklinks.go @@ -0,0 +1,277 @@ +package app + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/render" + "github.com/danielhanold/docket/internal/reposetup" +) + +// repository_repair_prbacklinks.go — `docket repository repair --pr-backlinks`: +// the one-time, human-confirmed repair of merged PRs whose docket:backlink block +// still names a change path that no longer exists (the active path of a change +// that has since been archived). It reads the pinned metadata corpus, reads the +// done changes' PR bodies in batches (≤25 per gh process), previews each PR with +// its current and corrected link, and — only under --yes, pinned to the previewed +// metadata revision — edits each one through the shared applyPRBacklinkRepoint, +// one PR at a time. A PR that cannot be read or edited is reported and skipped; +// it never aborts the batch. A second run finds nothing. + +// RepairGitHub is the GitHub seam this repair reads and edits merged PR bodies +// through. *githubcli.Client satisfies it. +type RepairGitHub interface { + DiscoverRepository(ctx context.Context, dir string) (githubcli.Repository, error) + ViewPullRequestsBatch(ctx context.Context, repo githubcli.Repository, numbers []int) (map[int]githubcli.BatchPRResult, error) + PRBodyEditor +} + +var _ RepairGitHub = (*githubcli.Client)(nil) + +// PRBacklinkRepair is one PR's row in the preview or the applied report. Current +// and Corrected are the backlink block's generated interior line (never authored +// prose). +type PRBacklinkRepair struct { + ID int `json:"id"` + PR int `json:"pr"` + Current string `json:"current,omitempty"` + Corrected string `json:"corrected,omitempty"` + Outcome string `json:"outcome"` + Message string `json:"message,omitempty"` +} + +// The closed per-PR outcomes. +const ( + PRBacklinkRepairPlanned = "planned" + PRBacklinkRepairRepointed = "repointed" + PRBacklinkRepairContended = "contended" + PRBacklinkRepairUnreadable = "unreadable" + PRBacklinkRepairFailed = "failed" +) + +// prRepairCandidate is one done change with a PR and the archive interior its +// PR's block must carry. +type prRepairCandidate struct { + id int + pr int + path string + interior string +} + +// prRepairPlanned is a planned row plus the bytes and revision the edit needs +// (kept out of the published row: body bytes are never reported). +type prRepairPlanned struct { + row PRBacklinkRepair + body string + revision string + path string + interior string +} + +func runPRBacklinkRepair(ctx context.Context, d SetupDeps, o RepairOptions) RepositoryRepairResult { + sc, metadataTip, refusal := repairPreflight(ctx, d) + if refusal != nil { + return *refusal + } + if d.GitHub == nil { + return repairInternalFailure(reposetup.StateHealthy, "wiring the GitHub client", + errors.New("no GitHub client is wired for --pr-backlinks")) + } + corpus, err := readCheckCorpus(ctx, d.Git, sc) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "reading the metadata corpus", err) + } + snap, ok := buildCorpusSnapshot(sc.cfg, corpus.records) + if !ok { + return repairInternalFailure(reposetup.StateHealthy, "building the metadata snapshot", + errors.New("the metadata corpus could not be built into a snapshot")) + } + cands, err := prRepairCandidates(snap, corpus.link) + if err != nil { + return repairInternalFailure(reposetup.StateHealthy, "rendering the archived backlinks", err) + } + if len(cands) == 0 { + return prRepairNoOp(metadataTip, nil) + } + repo, err := d.GitHub.DiscoverRepository(ctx, sc.repo.PrimaryWorktree) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "resolving the GitHub repository", err) + } + planned, skipped := planPRRepairs(ctx, d.GitHub, repo, cands) + if len(planned) == 0 { + return prRepairNoOp(metadataTip, skipped) + } + if !o.Authorized { + return prRepairConfirmationRequired(metadataTip, planned, skipped) + } + if migrateSourceMoved(o.ExpectedSource, metadataTip) { + return repairContended(metadataTip, o.ExpectedSource) + } + rows := append([]PRBacklinkRepair(nil), skipped...) + repointed, failed := 0, 0 + for _, p := range planned { + r := applyPRBacklinkRepoint(ctx, d.GitHub, repo, p.row.PR, p.body, p.revision, p.path, p.interior) + row := p.row + switch r.outcome { + case prBacklinkRepointed, prBacklinkAlready: + row.Outcome = PRBacklinkRepairRepointed + repointed++ + case prBacklinkContended: + row.Outcome, row.Message = PRBacklinkRepairContended, r.detail + failed++ + default: + row.Outcome, row.Message = PRBacklinkRepairFailed, r.detail + failed++ + } + rows = append(rows, row) + } + return prRepairApplied(metadataTip, rows, repointed, failed) +} + +// prRepairCandidates lists every done change carrying a PR reference, sorted by +// id, with the archive interior its PR's block must carry. +func prRepairCandidates(snap domain.Snapshot, link render.LinkContext) ([]prRepairCandidate, error) { + var out []prRepairCandidate + for _, c := range snap.Changes() { + if c.Status() != domain.StatusDone || !finalizeHasPRRef(c) { + continue + } + n, ok := parsePRNumber(c.PR().Value) + if !ok { + continue + } + block, err := render.BacklinkContent(c, link) + if err != nil { + return nil, fmt.Errorf("change %04d: %w", int(c.ID()), err) + } + out = append(out, prRepairCandidate{id: int(c.ID()), pr: n, path: c.Path(), interior: backlinkInterior(block)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) + return out, nil +} + +// planPRRepairs reads the candidates' PR bodies in ≤25-number batches and keeps +// the ones whose block does not name the archive path. A failed batch, an +// unresolved slot, or malformed markers is an `unreadable` row, never a silent drop. +func planPRRepairs(ctx context.Context, gh RepairGitHub, repo githubcli.Repository, cands []prRepairCandidate) ([]prRepairPlanned, []PRBacklinkRepair) { + byPR := map[int][]prRepairCandidate{} + var numbers []int + for _, c := range cands { + byPR[c.pr] = append(byPR[c.pr], c) + numbers = append(numbers, c.pr) + } + read := map[int]githubcli.BatchPRResult{} + failMsg := map[int]string{} + for _, chunk := range sweepChunkInts(sweepDedupeSortAsc(numbers), sweepPRBatchCap) { + res, err := gh.ViewPullRequestsBatch(ctx, repo, chunk) + for _, n := range chunk { + switch { + case err != nil: + failMsg[n] = "the pull-request batch could not be read: " + err.Error() + case !res[n].Found: + failMsg[n] = "the pull request could not be resolved" + default: + read[n] = res[n] + } + } + } + var planned []prRepairPlanned + var skipped []PRBacklinkRepair + for _, c := range cands { + br, ok := read[c.pr] + if !ok { + skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairUnreadable, Message: failMsg[c.pr]}) + continue + } + _, current, present, needs, err := planPRBacklinkRepoint([]byte(br.PR.Body), c.path, c.interior) + if err != nil { + skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairUnreadable, + Message: "the pull-request body carries a malformed docket:backlink block"}) + continue + } + if !present || !needs { + continue + } + planned = append(planned, prRepairPlanned{ + row: PRBacklinkRepair{ID: c.id, PR: c.pr, Current: current, Corrected: c.interior, Outcome: PRBacklinkRepairPlanned}, + body: br.PR.Body, + revision: br.PR.Revision, + path: c.path, + interior: c.interior, + }) + } + return planned, skipped +} + +func prRepairRows(planned []prRepairPlanned, skipped []PRBacklinkRepair) []PRBacklinkRepair { + rows := make([]PRBacklinkRepair, 0, len(planned)+len(skipped)) + for _, p := range planned { + rows = append(rows, p.row) + } + return append(rows, skipped...) +} + +func writePRRepairRows(b *strings.Builder, rows []PRBacklinkRepair) { + for _, r := range rows { + fmt.Fprintf(b, " PR #%d (change %04d): %s\n", r.PR, r.ID, r.Outcome) + if r.Current != "" { + fmt.Fprintf(b, " current: %s\n", r.Current) + } + if r.Corrected != "" { + fmt.Fprintf(b, " corrected: %s\n", r.Corrected) + } + if r.Message != "" { + fmt.Fprintf(b, " %s\n", r.Message) + } + } +} + +func prRepairConfirmationRequired(metadataTip string, planned []prRepairPlanned, skipped []PRBacklinkRepair) RepositoryRepairResult { + rows := prRepairRows(planned, skipped) + out := newRepairResult(ResultInvalidState, RepositoryRepairResult{ + RepositoryState: repairConfirmationRequiredState, + SourceRevision: metadataTip, + PRBacklinks: rows, + }) + var b strings.Builder + fmt.Fprintf(&b, "docket repository repair --pr-backlinks — preview (metadata %s)\n", metadataTip) + writePRRepairRows(&b, rows) + fmt.Fprintf(&b, "confirmation required: re-run with --yes to repoint these %d pull-request backlink(s)", len(planned)) + out.human = b.String() + return out +} + +func prRepairNoOp(metadataTip string, skipped []PRBacklinkRepair) RepositoryRepairResult { + out := newRepairResult(ResultNoOp, RepositoryRepairResult{ + RepositoryState: string(reposetup.StateHealthy), + SourceRevision: metadataTip, + PRBacklinks: skipped, + }) + var b strings.Builder + fmt.Fprintf(&b, "repository repair --pr-backlinks: no pull-request backlink to repoint at metadata %s\n", metadataTip) + writePRRepairRows(&b, skipped) + out.human = strings.TrimRight(b.String(), "\n") + return out +} + +func prRepairApplied(metadataTip string, rows []PRBacklinkRepair, repointed, failed int) RepositoryRepairResult { + result := ResultApplied + if repointed == 0 && failed > 0 { + result = ResultExternalFailed + } + out := newRepairResult(result, RepositoryRepairResult{ + RepositoryState: string(reposetup.StateHealthy), + SourceRevision: metadataTip, + PRBacklinks: rows, + }) + var b strings.Builder + fmt.Fprintf(&b, "repository repair --pr-backlinks: %d repointed, %d not repointed\n", repointed, failed) + writePRRepairRows(&b, rows) + out.human = strings.TrimRight(b.String(), "\n") + return out +} diff --git a/internal/cli/repository.go b/internal/cli/repository.go index de3c4a813..32a87e256 100644 --- a/internal/cli/repository.go +++ b/internal/cli/repository.go @@ -13,6 +13,7 @@ import ( "github.com/danielhanold/docket/internal/app" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/githubcli" ) // This file is the `docket repository` command family: thin adapters that @@ -42,6 +43,16 @@ var ( repositoryRepairRunner = func(ctx context.Context, d app.SetupDeps, o app.RepairOptions) app.OperationResult { return app.RunRepositoryRepair(ctx, d, o) } + // repositoryRepairGitHub constructs the GitHub client `repository repair + // --pr-backlinks` reads and edits PR bodies through. It is called only when + // that flag is set; tests replace it. + repositoryRepairGitHub = func() (app.RepairGitHub, error) { + gh, err := githubcli.NewClient() + if err != nil { + return nil, err + } + return gh, nil + } repositoryPrepareRunner = func(ctx context.Context, d app.SetupDeps, o app.PrepareOptions) app.OperationResult { return app.RunRepositoryPrepare(ctx, d, o) } @@ -222,9 +233,9 @@ func newRepositoryRepairCommand(setResult func(app.OperationResult)) *cobra.Comm Short: "Preview and apply the mechanical repairs `repository check` reports on a migrated repository", Args: cobra.NoArgs, // metadata-write: one repair descendant published to the metadata branch - // under an exact lease. It never touches the local .docket worktree (the - // result names `docket repository prepare` to sync it). - Annotations: capability("repository.repair", EffectMetadataWrite), + // under an exact lease. external-write: --pr-backlinks edits merged PR + // descriptions on GitHub. It never touches the local .docket worktree. + Annotations: capability("repository.repair", EffectExternalWrite, EffectMetadataWrite), RunE: func(c *cobra.Command, _ []string) error { repoDir, err := resolveRepoDir(c) if err != nil { @@ -235,13 +246,21 @@ func newRepositoryRepairCommand(setResult func(app.OperationResult)) *cobra.Comm return err } deps := app.SetupDeps{Git: client, RepoDir: repoDir} + prBacklinks, _ := c.Flags().GetBool("pr-backlinks") + if prBacklinks { + gh, err := repositoryRepairGitHub() + if err != nil { + return err + } + deps.GitHub = gh + } yes, _ := c.Flags().GetBool("yes") if yes { - setResult(repositoryRepairRunner(c.Context(), deps, app.RepairOptions{Authorized: true})) + setResult(repositoryRepairRunner(c.Context(), deps, app.RepairOptions{Authorized: true, PRBacklinks: prBacklinks})) return nil } - preview := repositoryRepairRunner(c.Context(), deps, app.RepairOptions{}) + preview := repositoryRepairRunner(c.Context(), deps, app.RepairOptions{PRBacklinks: prBacklinks}) jsonMode, _ := c.Flags().GetBool("json") confirmable, ok := preview.(interface{ ConfirmationRequired() bool }) if jsonMode || !repositoryConfirmInteractive() || !ok || !confirmable.ConfirmationRequired() { @@ -258,12 +277,13 @@ func newRepositoryRepairCommand(setResult func(app.OperationResult)) *cobra.Comm if p, ok := preview.(interface{ SourceRev() string }); ok { expected = p.SourceRev() } - setResult(repositoryRepairRunner(c.Context(), deps, app.RepairOptions{Authorized: true, ExpectedSource: expected})) + setResult(repositoryRepairRunner(c.Context(), deps, app.RepairOptions{Authorized: true, ExpectedSource: expected, PRBacklinks: prBacklinks})) return nil }, } cmd.Flags().String("repo-dir", "", "repository `dir` to operate on (default: current directory)") cmd.Flags().Bool("yes", false, "authorize the previewed repairs without an interactive confirmation") + cmd.Flags().Bool("pr-backlinks", false, "repoint merged pull requests whose change backlink names a path that no longer exists (reads and edits PR descriptions on GitHub)") return cmd } diff --git a/internal/cli/repository_test.go b/internal/cli/repository_test.go index 210d495eb..627dbb5c1 100644 --- a/internal/cli/repository_test.go +++ b/internal/cli/repository_test.go @@ -400,15 +400,15 @@ func (r fakeRepairResult) SourceRev() string { return r.source } func (r fakeRepairResult) ConfirmationRequired() bool { return r.confirm } // TestRepositoryRepairRegisteredWithCapability proves `repository repair` is a -// registered leaf carrying exactly --repo-dir and --yes, the catalog id -// repository.repair, and exactly the metadata-write effect. +// registered leaf carrying --repo-dir, --yes, and --pr-backlinks, the catalog id +// repository.repair, and exactly the external-write and metadata-write effects. func TestRepositoryRepairRegisteredWithCapability(t *testing.T) { root := captureTree(t) cmd, _, err := root.Find([]string{"repository", "repair"}) if err != nil || cmd == nil || cmd.Name() != "repair" { t.Fatalf("repository repair not registered: cmd=%v err=%v", cmd, err) } - for _, flag := range []string{"repo-dir", "yes"} { + for _, flag := range []string{"repo-dir", "yes", "pr-backlinks"} { if cmd.Flags().Lookup(flag) == nil { t.Errorf("repository repair: missing --%s flag", flag) } @@ -419,8 +419,8 @@ func TestRepositoryRepairRegisteredWithCapability(t *testing.T) { if got := cmd.Annotations[capAnnotationID]; got != "repository.repair" { t.Errorf("capability id = %q, want repository.repair", got) } - if got := cmd.Annotations[capAnnotationEffects]; got != string(EffectMetadataWrite) { - t.Errorf("effects = %q, want exactly %q", got, EffectMetadataWrite) + if got, want := cmd.Annotations[capAnnotationEffects], "external-write metadata-write"; got != want { + t.Errorf("effects = %q, want exactly %q", got, want) } } @@ -485,6 +485,59 @@ func TestRepositoryRepairInteractiveConfirmReinvokes(t *testing.T) { } } +// TestRepositoryRepairGitHubOnlyWithPRBacklinks proves the GitHub client is +// built and wired only under --pr-backlinks, and the flag reaches the service. +func TestRepositoryRepairGitHubOnlyWithPRBacklinks(t *testing.T) { + built := 0 + oldGH := repositoryRepairGitHub + repositoryRepairGitHub = func() (app.RepairGitHub, error) { built++; return nil, nil } + var seen []app.RepairOptions + oldR := repositoryRepairRunner + repositoryRepairRunner = func(ctx context.Context, d app.SetupDeps, o app.RepairOptions) app.OperationResult { + seen = append(seen, o) + return fakeRepairResult{Envelope: app.NewEnvelope("repository.repair", app.ResultNoOp), source: "abc"} + } + defer func() { repositoryRepairGitHub = oldGH; repositoryRepairRunner = oldR }() + + runCLI(t, "repository", "repair", "--yes") + if built != 0 || len(seen) != 1 || seen[0].PRBacklinks { + t.Fatalf("plain repair built GitHub %d time(s) / opts %+v; want none", built, seen) + } + runCLI(t, "repository", "repair", "--pr-backlinks", "--yes") + if built != 1 || len(seen) != 2 || !seen[1].PRBacklinks || !seen[1].Authorized { + t.Fatalf("--pr-backlinks: built=%d opts=%+v", built, seen) + } +} + +// TestRepositoryRepairPRBacklinksInteractiveConfirmKeepsFlag proves the +// confirmed re-invocation of a --pr-backlinks preview still selects the +// PR-backlink repair, pinned to the previewed revision. +func TestRepositoryRepairPRBacklinksInteractiveConfirmKeepsFlag(t *testing.T) { + var calls []app.RepairOptions + oldGH := repositoryRepairGitHub + repositoryRepairGitHub = func() (app.RepairGitHub, error) { return nil, nil } + old := repositoryRepairRunner + repositoryRepairRunner = func(ctx context.Context, d app.SetupDeps, o app.RepairOptions) app.OperationResult { + calls = append(calls, o) + return fakeRepairResult{Envelope: app.NewEnvelope("repository.repair", app.ResultInvalidState), source: "pinnedtip", confirm: !o.Authorized} + } + oldI := repositoryConfirmInteractive + repositoryConfirmInteractive = func() bool { return true } + defer func() { + repositoryRepairRunner = old + repositoryConfirmInteractive = oldI + repositoryRepairGitHub = oldGH + }() + + _, _, _ = runCLIStdin(t, "y\n", "repository", "repair", "--pr-backlinks") + if len(calls) != 2 { + t.Fatalf("runner called %d times, want preview then authorized", len(calls)) + } + if !calls[0].PRBacklinks || calls[0].Authorized || !calls[1].PRBacklinks || !calls[1].Authorized || calls[1].ExpectedSource != "pinnedtip" { + t.Errorf("calls = %+v, want a --pr-backlinks preview then an authorized --pr-backlinks run pinned to pinnedtip", calls) + } +} + // TestRepositoryRepairInteractiveDeclineDoesNotAuthorize proves `n` never // re-invokes authorized. func TestRepositoryRepairInteractiveDeclineDoesNotAuthorize(t *testing.T) { From b547c7b13c021a3cc8bf9f610a54f6d6fb5b5b81 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:14:38 -0400 Subject: [PATCH 08/13] docs: describe repointing the merged PR's change backlink and repair --pr-backlinks --- docs/concepts/finalize-sequencer.md | 6 +++++- docs/reference/glossary.md | 11 +++++++++++ internal/assets/embedded/manifest.json | 6 +++--- .../tree/skills/docket-finalize-change/SKILL.md | 6 +++--- internal/repoguard/budgets_test.go | 2 +- skills/docket-finalize-change/SKILL.md | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/concepts/finalize-sequencer.md b/docs/concepts/finalize-sequencer.md index fa6a825cc..987f36c30 100644 --- a/docs/concepts/finalize-sequencer.md +++ b/docs/concepts/finalize-sequencer.md @@ -84,7 +84,11 @@ to it. - Closeout proves the merge landed, then marks the change `done` and archives it on the `docket` branch (a stacked change merged into its parent is marked `stacked-merged` instead), and retargets the backlinks in the change's spec, plan, - and results files to the archived record. Nothing is copied to the + and results files to the archived record. It also repoints the backlink at the + top of the merged pull request's description to the archived record. A + description edit that fails leaves a `pr-backlink-pending` finding (the change + stays `done`), and `docket finalize cleanup` and the maintenance sweep retry it. + Nothing is copied to the integration branch. A backlink retarget that fails leaves a `final-backlink-pending` finding (the change stays `done`), which `docket finalize cleanup` repairs. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 09110b1ac..41ea4c8bb 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -288,6 +288,8 @@ docket change claim --id 412 --revision A derived view is anything rendered from the change files rather than authored: the board, each change's `## Artifacts` link block, and the `docket:backlink` block stamped at the top of every spec, plan, results file, and PR body. Each has exactly one writer and is never hand-edited. +When a change closes out, every one of these backlinks, the one in the merged PR's description +included, is repointed to the archived record. **Used for:** keeping links between a change and its artifacts correct in both directions. @@ -1493,6 +1495,15 @@ docket repository migrate # legacy repository only docket repository repair # preview; add --yes to apply ``` +`--pr-backlinks` repairs a different thing: merged pull requests whose description backlink still +names a change path that no longer exists. It reads those PR descriptions on GitHub, previews each +PR with its current and corrected link, and edits them one at a time only with `--yes`. A PR it +cannot read or edit is reported and skipped. + +```sh +docket repository repair --pr-backlinks # preview; add --yes to apply +``` + ### Status The read-only report of backlog state, readiness, selection, and repository health. It never writes diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 393e5a2f1..a807d9f53 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:236684a386e29057e6ba138b6a6ec65872bf385202738fab1b22fa4a5d29e0ee", + "asset_set_id": "sha256:fac939cae9ebd25ee74f63f2dc277af3bf2181b787106ba3ab43a874723be6c2", "entries": [ { "path": ".docket.example.yml", @@ -357,8 +357,8 @@ "path": "skills/docket-finalize-change/SKILL.md", "role": "skill", "mode": 420, - "size": 41193, - "sha256": "16cbe442b893b2ba9ccfcd46d64d7fbf266cd2348c1e8d22498d6e037ee9223c" + "size": 41505, + "sha256": "a3fde354c1d5106ffeb35425c61c2025e6cf0f4e5d2fe6ba1617a472cc36b389" }, { "path": "skills/docket-finalize-change/references/gate-failure.md", diff --git a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md index 079472c7a..b691dce24 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md @@ -158,16 +158,16 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, and the merged PR description's backlink repointed to the archived record, validated, committed by explicit path, lease-pushed. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. - `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). -- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. +- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. ### 10. Cleanup — Docket-owned resources only -The `finalize.cleanup` operation with `--id `. An ordered, independently retryable suffix: repair a pending backlink first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. +The `finalize.cleanup` operation with `--id `. An ordered, independently retryable suffix: repair a pending backlink (artifact files and the PR description) first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. ### 11. Integration sync — best-effort, end-of-run diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 78062f541..40c6770ae 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -203,7 +203,7 @@ var skillBudgets = []skillBudget{ {"docket-convention/references/close-out.md", 141, 1349}, // 0502: "loop" means /loop only (144/1375 -> 144/1374); 0502: one metadata layout — the docket branch and the .docket/ worktree (144/1376 -> 144/1375); 0502: the scripts/.md contract pointer becomes the schema operation (144/1374 -> 144/1376); 0502: the terminal-publication step is deleted (240/2150 -> 144/1374); 0474: reference renamed (ADR-0129 row 59a); ceilings unchanged; 0502: drop change citations and Go v1 narration (144/1374 -> 144/1356); 0502: the docket-source test citations are deleted (144/1356 -> 141/1349) {"docket-convention/references/learnings.md", 66, 457}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (66/455 -> 66/457); 0502: learning.record/learning.update are the writers; the capacity section is deleted (84/580 -> 66/455) {"docket-convention/references/stacked-changes.md", 214, 2149}, // 0502: typed operations and binary-emitted findings replace the Bash-era names (213/2125 -> 214/2149); 0502: the deferred descendant-publication sentence is deleted (215/2140 -> 213/2125); 0327: +carry-preservation contract prose (see note above) - {"docket-finalize-change/SKILL.md", 239, 5936}, // 0525 review: workspace-remnant rides any finished-workspace disposition and is named in the final report; branch-rules-unavailable comes only from the probing run (239/5910 -> 239/5936); 0525: plan-gated merges carry branch-rules-unavailable into closeout notes; cleanup reports workspace-remnant and names what blocked a workspace (239/5820 -> 239/5910); 0515 review: the refused-notes fallback names every dropped note in the final report (238/5809 -> 239/5820); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (238/5708 -> 238/5809); 0515: the repair sign-off is retired; a green repair merges (238/5808 -> 238/5708); 0517: the repaired-head evidence.record carries --owner finalize and records finalize.test_command (238/5790 -> 238/5808); 0502 review: clear-block re-reads the revision after the block lands (238/5771 -> 238/5790); 0502: repair sign-off records finalize.block before publishing the repaired head (238/5733 -> 238/5771); 0502: "loop" means /loop only (238/5729 -> 238/5733); 0502: repair sign-off is the human running finalize.clear-block (238/5666 -> 238/5729); 0502: the repaired head is re-gated through the gate driver (236/5568 -> 238/5666); 0502: the dummy-mode paragraph is deleted (238/5641 -> 236/5568); 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) + {"docket-finalize-change/SKILL.md", 239, 5984}, // 0529: close-out repoints the merged PR description's backlink; pr-backlink-pending is a retryable warning (239/5936 -> 239/5984); 0525 review: workspace-remnant rides any finished-workspace disposition and is named in the final report; branch-rules-unavailable comes only from the probing run (239/5910 -> 239/5936); 0525: plan-gated merges carry branch-rules-unavailable into closeout notes; cleanup reports workspace-remnant and names what blocked a workspace (239/5820 -> 239/5910); 0515 review: the refused-notes fallback names every dropped note in the final report (238/5809 -> 239/5820); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (238/5708 -> 238/5809); 0515: the repair sign-off is retired; a green repair merges (238/5808 -> 238/5708); 0517: the repaired-head evidence.record carries --owner finalize and records finalize.test_command (238/5790 -> 238/5808); 0502 review: clear-block re-reads the revision after the block lands (238/5771 -> 238/5790); 0502: repair sign-off records finalize.block before publishing the repaired head (238/5733 -> 238/5771); 0502: "loop" means /loop only (238/5729 -> 238/5733); 0502: repair sign-off is the human running finalize.clear-block (238/5666 -> 238/5729); 0502: the repaired head is re-gated through the gate driver (236/5568 -> 238/5666); 0502: the dummy-mode paragraph is deleted (238/5641 -> 236/5568); 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 161, 2109}, // 0515 review: the refused-notes fallback names every dropped note in the final report (160/2098 -> 161/2109); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (153/2005 -> 160/2098); 0515: the repair sign-off is retired; a green repair merges (160/2086 -> 153/2005); 0517: the repaired-head evidence.record carries --owner finalize (159/2081 -> 160/2086); 0502 review: clear-block re-reads the revision after the block lands (157/2062 -> 159/2081); 0502: repair sign-off records finalize.block before publishing the repaired head (155/2028 -> 157/2062); 0502: the embedded-bundle rule is conditioned on docket's own repository (155/2024 -> 155/2028); 0502: "loop" means /loop only (155/2027 -> 155/2024); 0502: repair sign-off is the human running finalize.clear-block; the marker lifecycle names what removes it (154/2013 -> 155/2027); 0502: the repaired head is re-gated through the gate driver (148/1937 -> 154/2013); 0497: +wait-for-the-leftover-group sentence (146/1901 -> 148/1937); 0493: the gate relaunch is retired (145/1892 -> 146/1901); 0491: the run id is retired (145/1894 -> 145/1892); 0490: the shared-slot note became the shared worktree-lock note (147/1901 -> 145/1894); 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) {"docket-groom-next/SKILL.md", 76, 2102}, // 0502 rebase onto 0509: carried the edit-a-stub revise route and groom-exit-scoped STOPs (76/2012 -> 76/2102); 0502: "loop" means /loop only (76/2011 -> 76/2012); 0502: one metadata layout — the docket branch and the .docket/ worktree (76/2010 -> 76/2011); 0502: selection bands key on auto_groomable itself (76/2009 -> 76/2010); 0502: invoke superpowers:brainstorming by name (76/2037 -> 76/2009); 0502: the dummy-mode pointer is deleted (78/2080 -> 76/2037); 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) {"docket-implement-next/SKILL.md", 216, 8443}, // 0517: evidence.record reads the gate command from build configuration (216/8438 -> 216/8443); 0502 rebase onto 0510: carried the Step 6.5 Backlog match paragraph and verdict pointers (214/8039 -> 216/8438); 0502 review: the controller dispatches the review tier wrapper; the skill dispatches nothing (214/8023 -> 214/8039); 0502: "loop" means /loop only (214/8058 -> 214/8057); 0502: one metadata layout — the docket branch and the .docket/ worktree (214/8042 -> 214/8058); 0502: the review payload names the resolved build.gate value; build_gate spellings become build.gate (214/8020 -> 214/8042); 0502: change.reclaim replaces reclaim-claims; the claim removes a leftover ## Run halted (214/8009 -> 214/8020); 0502: terminal-publication narration is deleted (214/8037 -> 214/8009); 0502: the severity floor is read as review.min_fix_severity through diagnostic.config (214/8034 -> 214/8037); 0502: the fixed role skills replace the $SKILL_* sites (214/8257 -> 214/8034); 0502: the dummy-mode paragraph and PR-body clause are deleted (216/8356 -> 214/8257); 0497: +Step 5 halted-build evidence kept verbatim in ## Run halted (216/8325 -> 216/8356); 0498 review: checkpoint (ii) persist wording (8320 -> 8325); 0498: +Step 6.5 review-findings homes, condensation exception, Human-actions class rule (214/8161 -> 216/8320); 0491: the run id is retired (214/8183 -> 214/8161); 0488 review: build-owned starts carry --change-id/--run-context (word ceiling 8162 -> 8183); 0488: run context and run id name only build-owned starts (word ceiling 8175 -> 8162); 0467 review fix: +the repair worker's post-fix re-run is a build-owned start (word ceiling 8165 -> 8175); 0467: +run id threaded to prepare-scope and build-owned starts (word ceiling 8080 -> 8165); 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +run-tracker resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose; 0502: drop change citations and Go v1 narration (214/8057 -> 214/8023) diff --git a/skills/docket-finalize-change/SKILL.md b/skills/docket-finalize-change/SKILL.md index 079472c7a..b691dce24 100644 --- a/skills/docket-finalize-change/SKILL.md +++ b/skills/docket-finalize-change/SKILL.md @@ -158,16 +158,16 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, and the merged PR description's backlink repointed to the archived record, validated, committed by explicit path, lease-pushed. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. - `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). -- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. +- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. ### 10. Cleanup — Docket-owned resources only -The `finalize.cleanup` operation with `--id `. An ordered, independently retryable suffix: repair a pending backlink first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. +The `finalize.cleanup` operation with `--id `. An ordered, independently retryable suffix: repair a pending backlink (artifact files and the PR description) first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. ### 11. Integration sync — best-effort, end-of-run From 9f20820d813b49bc2df98d969644fcc05e9dd6b6 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:15:20 -0400 Subject: [PATCH 09/13] docs(results): change 0529 build checkpoint --- ...backlink-when-the-change-is-arc-results.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md diff --git a/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md b/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md new file mode 100644 index 000000000..5b223629e --- /dev/null +++ b/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md @@ -0,0 +1,63 @@ + +> ↩ **[Change 0529 — Repoint a merged PR's change backlink when the change is archived](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0529-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc.md)** + +# Repoint a merged PR's change backlink when the change is archived — Results + +**Human action:** After merge, one human step is needed: preview and then run the one-time `docket repository repair --pr-backlinks` repair to fix the PRs whose change link is already dead. Nothing else needs a human before merge. + +## Outcome + +Before this change, every PR docket opened linked to its change record at `docs/changes/active/...` on the `docket` branch. Close-out moves the record to `docs/changes/archive/...`, so every merged PR's change link went dead. + +Now, when close-out archives a merged change, it rewrites only the docket-owned backlink block in that PR's description so it points at the archived record. Authored text in the description is left byte-for-byte as it was. A PR with no backlink block (written by hand) is left alone. The edit is best effort: if GitHub refuses or the result is unknown, close-out still succeeds and reports a pending warning, and the next cleanup or full maintenance sweep retries it. + +A new, explicit `docket repository repair --pr-backlinks` lists every merged PR whose backlink still names a path that no longer exists, with the current and corrected link, and with `--yes` fixes them one at a time. A PR that can't be read or written is reported and skipped. Plain `repository repair`, `repository check`, and `repository prepare` still never read PR descriptions. + +Departures from the spec: + +- `finalize.closeout` now declares the `external-write` effect (it edits GitHub). The spec only named this for `repository.repair`. +- `--pr-backlinks` runs only the PR repair, not the usual metadata repairs; plain `repository repair` still does those. +- Every full-scope maintenance sweep now reads the descriptions of done changes' PRs in batches of 25 (roughly ceil(done/25) GitHub GraphQL calls, about 10 for this repository today). The implementation-scope sweep reads none. + +## Human actions and testing + +### Important — run the one-time PR backlink repair after merge + +About 168 already-merged PRs in this repository link to a change record that no longer exists. Only the repair fixes them; close-out only handles changes archived from now on. If skipped, those links stay dead. + +Prerequisites: this change merged and the installed `docket` binary rebuilt from `main`; `gh` authenticated with write access to the repository. + +1. Run `docket repository repair --pr-backlinks --repo-dir /Users/homer/dev/docket`. + Expected: a preview listing each PR with its current `active/` link and the corrected `archive/` link, and a note that confirmation is required. A read-only preview during the build planned 168 PRs, 0 unreadable. +2. Spot-check two or three listed PRs on GitHub: the corrected link should open the archived change record. +3. Run the same command with `--yes`. + Expected: per-PR outcomes, all repointed (or individually reported if one fails). +4. Run step 1 again. + Expected: nothing left to repair. + +This edits PR descriptions on GitHub; there is no undo command, though each PR's description history on GitHub keeps the prior text. + +### Optional — watch the first real close-out + +The automated tests use a fake GitHub. After merge, the next finalized change's PR description should link to `docs/changes/archive/...` once close-out runs. If close-out reports a `pr-backlink-pending` warning instead, GitHub may be storing the edited description differently from what docket sent (see Known issues). + +## Verification performed + +- A real, read-only `gh api graphql` probe confirmed that merged PR #250 is `MERGED` with `viewerCanUpdate: true` (gh 2.101.0), so a merged PR's description is editable. No GitHub write was made against a real PR during the build. +- Each plan task ran focused tests red then green; mutation probes reddened the guards for the close-out leg (acceptance criterion 5), the cleanup retry, the sweep's unknown-body handling, the sweep reader's identity memo, the edit's revision check, and the "no GitHub reads without `--pr-backlinks`" guard (acceptance criterion 4). +- A read-only preview of `repository repair --pr-backlinks` against this repository planned 168 PRs, 0 unreadable; `--yes` was not run. +- Bundle regeneration was checked with `genassets -check`; the living-docs, comment-anchor and skill-size guards pass. + +## Known issues and follow-ups + +### GitHub may normalize an edited description + +When docket edits a PR description it re-reads it to confirm. If GitHub stores the text with different bytes (for example, changed line endings), the re-read won't match and docket reports the leg as pending, retrying on each sweep. This is suspected, not observed; the tests use a fake GitHub. Watch the first real close-out after merge. No backlog follow-up needed unless it happens. + +### Full sweeps now make more GitHub calls + +Every full-scope maintenance sweep reads done changes' PR descriptions in batches, about 10 extra GraphQL calls today and growing with the archive. This is by design so the sweep can retry failed repoints. If it becomes a cost problem, the sweep could skip records already confirmed repointed. + +### Older PRs without a backlink block are not touched + +The preview found 168 PRs to fix rather than the roughly 250 estimated at grooming. Older merged PRs that predate the backlink block have nothing to repoint and are deliberately left alone, matching the spec. From 2e16a4435d60e7556fa0c8b3fa7e646d370d5df8 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:31:49 -0400 Subject: [PATCH 10/13] test(maintenance): pin the full sweep repointing legacy merged-PR backlinks; docs say so The full maintenance sweep assesses every archived done record's merged PR body and its cleanup repoints a backlink still naming the old active/ path, with no prior close-out attempt; the implementation-scope sweep defers the record untouched. Living docs and the docket-status skill now say the full sweep makes this repair unpreviewed and that repository repair --pr-backlinks is the previewed way to do it all at once. --- docs/concepts/finalize-sequencer.md | 6 ++- docs/guide/keeping-the-backlog-honest.md | 5 +- docs/reference/glossary.md | 4 +- internal/app/maintenance_assess_test.go | 50 +++++++++++++++++++ internal/assets/embedded/manifest.json | 6 +-- .../tree/skills/docket-status/SKILL.md | 2 +- internal/repoguard/budgets_test.go | 2 +- skills/docket-status/SKILL.md | 2 +- 8 files changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/concepts/finalize-sequencer.md b/docs/concepts/finalize-sequencer.md index 987f36c30..53f1f6d00 100644 --- a/docs/concepts/finalize-sequencer.md +++ b/docs/concepts/finalize-sequencer.md @@ -88,7 +88,11 @@ to it. top of the merged pull request's description to the archived record. A description edit that fails leaves a `pr-backlink-pending` finding (the change stays `done`), and `docket finalize cleanup` and the maintenance sweep retry it. - Nothing is copied to the + The full maintenance sweep (`--scope full`) also repoints, without a preview, any + merged pull request whose description backlink still names a change's old + `active/` path, however long ago that change was archived; + `docket repository repair --pr-backlinks` is the explicit way to preview and + repoint them all at once. Nothing is copied to the integration branch. A backlink retarget that fails leaves a `final-backlink-pending` finding (the change stays `done`), which `docket finalize cleanup` repairs. diff --git a/docs/guide/keeping-the-backlog-honest.md b/docs/guide/keeping-the-backlog-honest.md index 1782133e3..1bb96cb4c 100644 --- a/docs/guide/keeping-the-backlog-honest.md +++ b/docs/guide/keeping-the-backlog-honest.md @@ -19,7 +19,10 @@ one pull request, tracked as one markdown file) has its pull request merged, som it to `done` and archive it. The deliberate way is to close the change out yourself right after the merge — see [Landing changes safely](./landing-changes.md). But you do not have to: the sweep is the safety net. In one pass it closes out every change whose pull request already merged, -retries any close-out cleanup that did not finish, and reclaims expired claims (below). Every +retries any close-out cleanup that did not finish, and reclaims expired claims (below). That +cleanup retry also repoints the backlink in a merged pull request's description when it still +names the change's old `active/` path, even for a change archived long ago; to preview every such +pull request first and repoint them all at once, run `docket repository repair --pr-backlinks`. Every change it touches re-renders the **board** (the generated overview of every change and its state, never edited by hand) in the same commit. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 41ea4c8bb..df420a155 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -1498,7 +1498,9 @@ docket repository repair # preview; add --yes to apply `--pr-backlinks` repairs a different thing: merged pull requests whose description backlink still names a change path that no longer exists. It reads those PR descriptions on GitHub, previews each PR with its current and corrected link, and edits them one at a time only with `--yes`. A PR it -cannot read or edit is reported and skipped. +cannot read or edit is reported and skipped. The full maintenance sweep makes the same repair, +without a preview, as it retries each `done` change's cleanup; `--pr-backlinks` is the way to see +every affected PR first and repoint them all at once. ```sh docket repository repair --pr-backlinks # preview; add --yes to apply diff --git a/internal/app/maintenance_assess_test.go b/internal/app/maintenance_assess_test.go index 2f0357d45..189f21e2a 100644 --- a/internal/app/maintenance_assess_test.go +++ b/internal/app/maintenance_assess_test.go @@ -10,6 +10,7 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository" "github.com/danielhanold/docket/internal/workspace" @@ -532,3 +533,52 @@ func TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown(t *testing.T) { t.Errorf("PR #%d was not returned, so it must be unknown", n42) } } + +// TestLegacyArchivedPRBacklinkIsFullSweepWorkOnly pins the full sweep's bulk +// reach: a legacy done record — archived long ago, with a PR whose description +// backlink still names the old active/ path and no close-out ever attempted the +// repoint — is enqueued and assessed as PR-backlink work by the FULL sweep, and +// the dispatched cleanup's PR-backlink leg repoints it (after which the record +// assesses as no work). The implementation-scope sweep never enqueues it: it +// defers the record as an unprobed historical count. +func TestLegacyArchivedPRBacklinkIsFullSweepWorkOnly(t *testing.T) { + blob := assessArchivedDoneBlob(41, "legacy") + f := newAssessFixture(t, []StatusBlob{blob}, nil) + n := prNumberOf(t, f, 41) + c, _ := f.inv.snap.Change(domain.ChangeID(41)) + eff := f.pin.Config.Effective + now := testClock().Now() + + // Implementation scope: deferred, never enqueued, never assessed. + implItems, implDeferred := sweepWorklist(f.inv.snap, nil, eff, now, SweepScopeImplementation) + if len(implItems) != 0 || implDeferred != 1 { + t.Fatalf("implementation scope must defer the legacy record untouched: items=%v deferred=%d", implItems, implDeferred) + } + + // Full scope: enqueued as a cleanup and assessed as PR-backlink work. + fullItems, fullDeferred := sweepWorklist(f.inv.snap, nil, eff, now, SweepScopeFull) + if fullDeferred != 0 || len(fullItems) != 1 || fullItems[0].id != 41 || fullItems[0].kind != sweepKindCleanup { + t.Fatalf("full scope must enqueue the legacy record as a cleanup: items=%v deferred=%d", fullItems, fullDeferred) + } + stale := prBodyWithActiveBacklink("docs/changes/active/0041-legacy.md", "prose") + _, actionable := f.assess(t, cleanWS(), prShared(map[int]string{n: stale}), 41) + if !actionableHas(actionable, 41) { + t.Fatalf("the full sweep must assess the legacy PR backlink as work; actionable=%v", actionable) + } + + // The dispatched cleanup's PR-backlink leg repoints it — no prior attempt needed. + pr := newFakePRBody(map[int]string{n: stale}) + cc := &closeoutContext{eff: eff, change: c, body: blob.Data, link: linkContextOf(f.pin)} + if fnd := finalizeCleanupPRBacklinkRepair(context.Background(), FinalizeDeps{PRBody: pr}, cc, githubcli.Repository{}, n); fnd != nil { + t.Fatalf("cleanup's PR-backlink leg returned a finding: %+v", *fnd) + } + got := pr.bodies[n] + if pr.edits != 1 || strings.Contains(got, "docs/changes/active/") || !strings.Contains(got, "`"+c.Path()+"`") || !strings.Contains(got, "prose") { + t.Fatalf("cleanup did not repoint the legacy PR backlink at %s (edits=%d):\n%s", c.Path(), pr.edits, got) + } + entries, again := f.assess(t, cleanWS(), prShared(map[int]string{n: got}), 41) + if len(again) != 0 { + t.Fatalf("a repointed PR backlink must assess as no work; actionable=%v", again) + } + assertEntry(t, findEntry(entries, 41), SweepDispSkipped, ReasonSweepSnapshotNoWork) +} diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index a807d9f53..24e2c7da3 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:fac939cae9ebd25ee74f63f2dc277af3bf2181b787106ba3ab43a874723be6c2", + "asset_set_id": "sha256:f8b99495a63fbd9e01bb0ffb0638efa3ccfff2ae3052d7773a2b3e8ad523a2c2", "entries": [ { "path": ".docket.example.yml", @@ -420,8 +420,8 @@ "path": "skills/docket-status/SKILL.md", "role": "skill", "mode": 420, - "size": 19531, - "sha256": "88c25d73c413dbd218e083b4bf4453083d79b5ad1324554af209451c64f401c7" + "size": 19720, + "sha256": "0f1a406746c0174c5da6968b6758b5ea56ba271b4b996d35c01f8124bb32d18a" } ] } diff --git a/internal/assets/embedded/tree/skills/docket-status/SKILL.md b/internal/assets/embedded/tree/skills/docket-status/SKILL.md index 4e07c2aed..4398d8679 100644 --- a/internal/assets/embedded/tree/skills/docket-status/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-status/SKILL.md @@ -43,7 +43,7 @@ unauthorized child, and never edits authored results; a destructive suffix never `unknown` prerequisite, and one item's failure never stops the independent items. Surface the per-item report; act only on the `blocked`/`failed`/`unknown` entries a human must see. -`--scope` takes a fixed set of allowed values. `full` — the default when omitted — is the whole worklist, including cleanup retries for every `done`/`stacked-merged` record. `implementation` is the implementation-startup preflight: it still schedules every current merged-implemented closeout, the cleanup suffix such a closeout carries in the same invocation, and the `reclaim.auto`-gated reclaims, but it does not enqueue an independent cleanup for a record that was already final at the pinned inventory. Those are reported as a deferred COUNT — a population deliberately not probed, never evidence anything is dirty or blocked; explicit full maintenance owns those retries, and a failed or interrupted suffix stays recoverable through `--scope full` or the targeted finalize cleanup. +`--scope` takes a fixed set of allowed values. `full` — the default when omitted — is the whole worklist, including cleanup retries for every `done`/`stacked-merged` record — which also repoint, unpreviewed, every merged PR description whose backlink still names an `active/` path (the `repository.repair` operation with `--pr-backlinks` previews that repair). `implementation` is the implementation-startup preflight: it still schedules every current merged-implemented closeout, the cleanup suffix such a closeout carries in the same invocation, and the `reclaim.auto`-gated reclaims, but it does not enqueue an independent cleanup for a record that was already final at the pinned inventory. Those are reported as a deferred COUNT — a population deliberately not probed, never evidence anything is dirty or blocked; explicit full maintenance owns those retries, and a failed or interrupted suffix stays recoverable through `--scope full` or the targeted finalize cleanup. ## Run the pass diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 40c6770ae..9907ee953 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -212,7 +212,7 @@ var skillBudgets = []skillBudget{ {"docket-implement-next/results-template.md", 70, 545}, // 0502 rebase onto 0510: carried the backlog verdict and next action in the Known issues placeholder (68/506 -> 70/545); 0502: inline review replaces the custom review skill (68/511 -> 68/510); 0498: +whole-branch review summary line in Verification performed (64/446 -> 68/511); 0440: reader-first template — action statement + merged Known issues (see note above); 0502: drop change citations and Go v1 narration (68/510 -> 68/506) {"docket-review/SKILL.md", 114, 965}, // 0502: the evidence rule keys on the payload's build.gate value; gate-off skipped records are expected (110/913 -> 114/965); 0410: +findings-return capture contract (see note above) {"docket-new-change/SKILL.md", 57, 1596}, // 0502 review: omitted draft-time scalars are described exactly (57/1591 -> 57/1596); 0502: "loop" means /loop only (57/1593 -> 57/1591); 0502: one metadata layout — the docket branch and the .docket/ worktree (57/1590 -> 57/1593); 0502: terminal-publication narration and the repository auto_groom default are deleted (57/1609 -> 57/1590); 0502: invoke superpowers:brainstorming by name (57/1636 -> 57/1609); 0502: the dummy-mode pointer is deleted (59/1674 -> 57/1636); 0445: +pointer to the docket-groom-next revise path after landing (word ceiling 1700 -> 1706); 0382: draft-time scalars moved into change.create (ceiling 1706 -> 1675) - {"docket-status/SKILL.md", 126, 2880}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) + {"docket-status/SKILL.md", 126, 2904}, // 0529 review: full-scope cleanup retries also repoint stale merged-PR description backlinks; repair --pr-backlinks previews them (126/2880 -> 126/2904); 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) } // wcLines counts lines the way `wc -l` does: the number of newline bytes. diff --git a/skills/docket-status/SKILL.md b/skills/docket-status/SKILL.md index 4e07c2aed..4398d8679 100644 --- a/skills/docket-status/SKILL.md +++ b/skills/docket-status/SKILL.md @@ -43,7 +43,7 @@ unauthorized child, and never edits authored results; a destructive suffix never `unknown` prerequisite, and one item's failure never stops the independent items. Surface the per-item report; act only on the `blocked`/`failed`/`unknown` entries a human must see. -`--scope` takes a fixed set of allowed values. `full` — the default when omitted — is the whole worklist, including cleanup retries for every `done`/`stacked-merged` record. `implementation` is the implementation-startup preflight: it still schedules every current merged-implemented closeout, the cleanup suffix such a closeout carries in the same invocation, and the `reclaim.auto`-gated reclaims, but it does not enqueue an independent cleanup for a record that was already final at the pinned inventory. Those are reported as a deferred COUNT — a population deliberately not probed, never evidence anything is dirty or blocked; explicit full maintenance owns those retries, and a failed or interrupted suffix stays recoverable through `--scope full` or the targeted finalize cleanup. +`--scope` takes a fixed set of allowed values. `full` — the default when omitted — is the whole worklist, including cleanup retries for every `done`/`stacked-merged` record — which also repoint, unpreviewed, every merged PR description whose backlink still names an `active/` path (the `repository.repair` operation with `--pr-backlinks` previews that repair). `implementation` is the implementation-startup preflight: it still schedules every current merged-implemented closeout, the cleanup suffix such a closeout carries in the same invocation, and the `reclaim.auto`-gated reclaims, but it does not enqueue an independent cleanup for a record that was already final at the pinned inventory. Those are reported as a deferred COUNT — a population deliberately not probed, never evidence anything is dirty or blocked; explicit full maintenance owns those retries, and a failed or interrupted suffix stays recoverable through `--scope full` or the targeted finalize cleanup. ## Run the pass From c26c95de91aa7143ae84fb3d657a006cb6236f6f Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:37:29 -0400 Subject: [PATCH 11/13] fix(finalize): cleanup retries a carried descendant's PR backlink before the destination refusal A carried stacked descendant merges into its parent's branch, so cleanup's merge-destination refusal returned before the PR-body repoint leg; a close-out repoint that failed had no reachable retry while the sweep kept re-dispatching cleanup. The non-destructive repoint now runs first and its finding rides the refusal; the destructive legs stay gated as before. --- internal/app/finalize_cleanup.go | 31 +++++--- .../app/finalize_cleanup_integration_test.go | 71 +++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/internal/app/finalize_cleanup.go b/internal/app/finalize_cleanup.go index 237bf5a3d..4872530aa 100644 --- a/internal/app/finalize_cleanup.go +++ b/internal/app/finalize_cleanup.go @@ -278,9 +278,24 @@ func finalizeCleanupDone(ctx context.Context, deps FinalizeDeps, cc *closeoutCon return cleanupRefusal(ResultBlocked, CleanupDispPending, ReasonCleanupNotMerged, "the pull request is not merged; cleanup preserves the resources", id) } + + // Leg 1b: repoint the merged PR's description backlink at the archived record + // when close-out left it pending. Best-effort and independent: a failure is a + // retryable pr-backlink-pending finding and never blocks the other legs. It is + // non-destructive and needs only the PR number and the archived record, so it + // runs BEFORE the merge-destination refusal: a carried stacked descendant + // (merged into its parent's branch, never the integration branch) has no other + // retry for a close-out repoint that failed. + var findings []StatusFinding + if f := finalizeCleanupPRBacklinkRepair(ctx, deps, cc, ghRepo, number); f != nil { + findings = append(findings, *f) + } + if facts.BaseRef != cc.integrationBranch { - return cleanupRefusal(ResultBlocked, CleanupDispPending, ReasonCleanupDestination, + r := cleanupRefusal(ResultBlocked, CleanupDispPending, ReasonCleanupDestination, "the verified merge destination is not the integration branch; cleanup preserves the resources", id) + r.Findings = append(r.Findings, findings...) + return r } // Delete the change's own feature ref by its RECORDED branch, never a @@ -288,29 +303,23 @@ func finalizeCleanupDone(ctx context.Context, deps FinalizeDeps, cc *closeoutCon // deletion — the branches are retained. featureBranch, berr := recordedBranch(cc.change) if berr != nil { - return cleanupRefusal(ResultInvalidState, CleanupDispPending, berr.Error(), + r := cleanupRefusal(ResultInvalidState, CleanupDispPending, berr.Error(), "the change's recorded feature branch is unusable; the branches are retained", id) + r.Findings = append(r.Findings, findings...) + return r } featureRef := gitcli.RefName(branchRefPrefix + featureBranch) git := cleanupGit(deps) - var findings []StatusFinding var removed []string - // Leg 1: repair the final backlinks first when needed (docket mode). A + // Leg 1: repair the final backlinks when needed (docket mode). A // failed/contended leg is a pending finding; it never blocks the independent // ref-deletion legs. if f := finalizeCleanupBacklinkRepair(ctx, deps, cc, facts); f != nil { findings = append(findings, *f) } - // Leg 1b: repoint the merged PR's description backlink at the archived record - // when close-out left it pending. Best-effort and independent: a failure is a - // retryable pr-backlink-pending finding and never blocks the other legs. - if f := finalizeCleanupPRBacklinkRepair(ctx, deps, cc, ghRepo, number); f != nil { - findings = append(findings, *f) - } - // Leg 2: remove the feature checkout through the landed manifest-fact-driven // Cleanup. A blocked workspace or an unanswerable inspection retains the // workspace and — because the branch may still be checked out — skips the ref diff --git a/internal/app/finalize_cleanup_integration_test.go b/internal/app/finalize_cleanup_integration_test.go index f0cfe9752..074a26efe 100644 --- a/internal/app/finalize_cleanup_integration_test.go +++ b/internal/app/finalize_cleanup_integration_test.go @@ -493,3 +493,74 @@ func TestIntegrationFinalizeCleanupPRBacklinkRetry(t *testing.T) { } }) } + +// TestIntegrationFinalizeCleanupPRBacklinkRetryCarriedDescendant proves a +// carried stacked descendant has a reachable PR-backlink retry: its PR merged +// into the ROOT's branch (never the integration branch), so cleanup refuses its +// destructive legs as destination-mismatch — but the non-destructive PR-body +// repoint still runs first, landing the edit close-out left pending. +func TestIntegrationFinalizeCleanupPRBacklinkRetryCarriedDescendant(t *testing.T) { + requireRealGit(t) + f := setupCloseoutFixture(t, planRepoModes()[0]) + descPlan := "docs/superpowers/plans/2026-08-16-gadget-plan.md" + descActive := groomPath(6, "gadget") + desc := closeoutRecord(6, "gadget", "stacked-merged", "github.com/acme/widget#8", "", descPlan, "") + desc = strings.Replace(desc, "stacked_on:\n", "stacked_on: 5\n", 1) + f.repo.writerAdvance(t, f.branch, map[string]string{ + descActive: desc, + descPlan: artifactWithBacklink(descActive, "Gadget plan", "The gadget plan."), + }) + childMerge := f.carryOntoRootFeature(t, map[string]string{"gadget.txt": "gadget work\n"}) + mergeCommit := f.mergeIntoBase(t) + f.fetchAllIntoInvocation(t) + merged := map[int]closeoutProbe{ + closeoutPR: {outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "main", mergeCommit)}, + 8: {outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(childMerge, "feat/"+f.slug, childMerge)}, + } + + // Close-out archives root + descendant, but every PR-body edit fails: the + // descendant's PR still names its ACTIVE record path. + pr := newFakePRBody(map[int]string{ + closeoutPR: artifactWithBacklink(groomPath(5, "widget"), "Root", "root prose"), + 8: artifactWithBacklink(descActive, "Child", "child prose"), + }) + pr.editErr = errors.New("gh: HTTP 502") + cdeps := f.closeoutDeps(&fakeCloseoutGitHub{repo: retargetRepo(), merged: merged}) + cdeps.PRBody = pr + if res := FinalizeCloseout(context.Background(), cdeps, f.repo.invocation, f.id, CloseoutNotes{}); res.Disposition != CloseoutDispRootArchived { + t.Fatalf("root carry = %q disp %q (%s)", res.Result, res.Disposition, res.Message) + } + if got := pr.bodies[8]; !strings.Contains(got, "`"+descActive+"`") { + t.Fatalf("fixture: the failed close-out edit must leave the active backlink:\n%s", got) + } + + // The retry: cleanup of the carried descendant, the edit now succeeding. + pr.editErr = nil + deps := f.cleanupDeps(&fakeCleanupGitHub{repo: retargetRepo(), merged: merged, openByHead: map[string][]githubcli.PullRequest{}}, f.deps.Client, f.svc) + deps.PRBody = pr + res := FinalizeCleanup(context.Background(), deps, f.repo.invocation, 6) + + if got := pr.bodies[8]; strings.Contains(got, "`"+descActive+"`") || !strings.Contains(got, "docs/changes/archive/2026-08-18-0006-gadget.md") { + t.Fatalf("cleanup did not repoint the carried descendant's PR backlink:\n%s", got) + } + // The destructive legs are still refused: nothing removed, the root's branch + // (which carries the descendant's merged work) intact. + if res.Result != ResultBlocked || res.Disposition != CleanupDispPending || res.Reason != ReasonCleanupDestination { + t.Fatalf("cleanup = %q disp %q reason %q, want blocked/pending/%s", res.Result, res.Disposition, res.Reason, ReasonCleanupDestination) + } + if len(res.RemovedRefs) != 0 || !f.localBranchPresent(t) || !f.remoteBranchPresent(t) { + t.Fatalf("a destination-mismatch cleanup must remove nothing; removed=%v", res.RemovedRefs) + } + + // A still-failing retry surfaces the retryable finding on the refusal. + pr.bodies[8] = artifactWithBacklink(descActive, "Child", "child prose") + pr.editErr = errors.New("gh: HTTP 502") + again := FinalizeCleanup(context.Background(), deps, f.repo.invocation, 6) + var pending bool + for _, fd := range again.Findings { + pending = pending || fd.Code == ReasonPRBacklinkPending + } + if again.Reason != ReasonCleanupDestination || !pending { + t.Fatalf("failing retry = reason %q findings %+v, want %s carrying a %s finding", again.Reason, again.Findings, ReasonCleanupDestination, ReasonPRBacklinkPending) + } +} From cb89d6e9044bd279a492e0eff09a629bdc07e530 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:44:47 -0400 Subject: [PATCH 12/13] fix(pr-backlinks): address review findings M3-M6 - M3: EditPullRequestBody's verify probe now tells a refused or lost edit (the body still equals the pre-edit body) from a race (a third body). The former is `unknown` with a bounded, redacted stderr excerpt; only the latter is `contended`. applyPRBacklinkRepoint's unknown detail says the edit was not applied or could not be verified, so a 403/locked PR is no longer reported as "the body changed since it was read". - M4: planPRRepairs uses byPR: a PR named by more than one done change is a `skipped` row for each sharer, never read or planned. - M5: docket-finalize-change places the PR-backlink repoint after the lease-pushed archive transaction as a separate best-effort follow-up, for done-archived and for root-archived roots and descendants. - M6: docket-convention names the PR-body backlink writers (pr.publish; finalize.closeout retried by finalize.cleanup and maintenance.sweep; repository.repair with --pr-backlinks) beside artifact.backlink, one shared renderer. Embedded assets regenerated; skill word budgets resized. --- internal/app/pr_backlink_repoint.go | 2 +- internal/app/pr_backlink_repoint_test.go | 19 ++++++++++ .../app/repository_repair_integration_test.go | 29 +++++++++++++++ internal/app/repository_repair_prbacklinks.go | 26 +++++++++++++- internal/assets/embedded/manifest.json | 10 +++--- .../tree/skills/docket-convention/SKILL.md | 2 +- .../skills/docket-finalize-change/SKILL.md | 4 +-- internal/githubcli/bodyedit.go | 34 +++++++++++++++--- .../githubcli/bodyedit_integration_test.go | 36 +++++++++++++++++++ internal/repoguard/budgets_test.go | 4 +-- skills/docket-convention/SKILL.md | 2 +- skills/docket-finalize-change/SKILL.md | 4 +-- 12 files changed, 153 insertions(+), 19 deletions(-) diff --git a/internal/app/pr_backlink_repoint.go b/internal/app/pr_backlink_repoint.go index 586e017bf..ae3e1d128 100644 --- a/internal/app/pr_backlink_repoint.go +++ b/internal/app/pr_backlink_repoint.go @@ -125,7 +125,7 @@ func applyPRBacklinkRepoint(ctx context.Context, ed PRBodyEditor, repo githubcli out, _, err := ed.EditPullRequestBody(ctx, repo, number, revision, string(updated)) switch { case err != nil: - return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit could not be verified: " + err.Error()} + return prBacklinkResult{outcome: prBacklinkUnknown, current: current, detail: "the edit was not applied or could not be verified: " + err.Error()} case out == githubcli.BodyEdited || out == githubcli.BodyAlready: return prBacklinkResult{outcome: prBacklinkRepointed, current: current} case out == githubcli.BodyContended: diff --git a/internal/app/pr_backlink_repoint_test.go b/internal/app/pr_backlink_repoint_test.go index cd4be89d1..d6ebedf9f 100644 --- a/internal/app/pr_backlink_repoint_test.go +++ b/internal/app/pr_backlink_repoint_test.go @@ -190,6 +190,25 @@ func TestRepointPRBacklinkFailuresArePendingFindings(t *testing.T) { } } +// TestRepointPRBacklinkRefusedEditIsNotARace: the body editor reports a refused +// edit (the verify probe found the pre-edit body) as unknown with gh's excerpt; +// the finding carries it and never claims the body changed under us. +func TestRepointPRBacklinkRefusedEditIsNotARace(t *testing.T) { + ed := newFakePRBody(map[int]string{7: prBodyWithActiveBacklink(tActive, "p")}) + ed.editErr = errors.New("githubcli edit-pull-request-body/invoke: external: gh pr edit exited 1 and the body edit was not applied: HTTP 403") + r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) + if r.outcome != prBacklinkUnknown { + t.Fatalf("outcome = %q, want %q", r.outcome, prBacklinkUnknown) + } + f := prBacklinkFinding(5, 7, r) + if f == nil || !strings.Contains(f.Message, "HTTP 403") || !strings.Contains(f.Message, "not applied") { + t.Fatalf("finding = %+v, want the refusal excerpt", f) + } + if strings.Contains(f.Message, "changed since it was read") { + t.Fatalf("a refused edit was reported as a race: %q", f.Message) + } +} + func TestRepointPRBacklinkNoBlockNoEditNoFinding(t *testing.T) { ed := newFakePRBody(map[int]string{7: "hand-written\n"}) r := repointPRBacklink(context.Background(), ed, githubcli.Repository{}, 7, tArchive, tInterior) diff --git a/internal/app/repository_repair_integration_test.go b/internal/app/repository_repair_integration_test.go index 0dc356342..07f90a83c 100644 --- a/internal/app/repository_repair_integration_test.go +++ b/internal/app/repository_repair_integration_test.go @@ -494,6 +494,35 @@ func TestIntegrationRepoRepairPRBacklinksSkipsUnreadable(t *testing.T) { } } +// TestIntegrationRepoRepairPRBacklinksSkipsSharedPR: two done changes naming one +// PR give that PR no single owner, so each is a skipped row and the PR is neither +// read nor planned; an unshared candidate beside them is still planned. +func TestIntegrationRepoRepairPRBacklinksSkipsSharedPR(t *testing.T) { + gh := &fakeRepairGitHub{t: t, bodies: map[int]string{ + 250: prBodyNaming(prRepairActive), + 260: prBodyNaming(prRepairActive), + }} + cands := []prRepairCandidate{ + {id: 363, pr: 250, path: prRepairPath, interior: "> x `" + prRepairPath + "`"}, + {id: 370, pr: 260, path: "docs/changes/archive/2026-09-01-0370-a.md", interior: "> a"}, + {id: 371, pr: 260, path: "docs/changes/archive/2026-09-01-0371-b.md", interior: "> b"}, + } + planned, skipped := planPRRepairs(context.Background(), gh, githubcli.Repository{}, cands) + if len(planned) != 1 || planned[0].row.PR != 250 { + t.Fatalf("planned = %+v, want only PR #250", planned) + } + if len(skipped) != 2 { + t.Fatalf("skipped = %+v, want both sharers of PR #260", skipped) + } + for i, id := range []int{370, 371} { + e := skipped[i] + if e.ID != id || e.PR != 260 || e.Outcome != PRBacklinkRepairSkipped || + !strings.Contains(e.Message, "0370, 0371") { + t.Fatalf("skipped[%d] = %+v", i, e) + } + } +} + // TestIntegrationRepoRepairRoutineCommandsReadNoPRBodies pins spec acceptance 4: // with a GitHub fake that fails on ANY call wired into SetupDeps, routine // `repository repair` (preview and --yes), `check`, and `prepare` never call it. diff --git a/internal/app/repository_repair_prbacklinks.go b/internal/app/repository_repair_prbacklinks.go index 091ea2b74..78ea71a5f 100644 --- a/internal/app/repository_repair_prbacklinks.go +++ b/internal/app/repository_repair_prbacklinks.go @@ -52,6 +52,10 @@ const ( PRBacklinkRepairContended = "contended" PRBacklinkRepairUnreadable = "unreadable" PRBacklinkRepairFailed = "failed" + // PRBacklinkRepairSkipped: two or more done changes name the same PR, so + // which record its backlink belongs to is ambiguous; it is neither read nor + // edited. + PRBacklinkRepairSkipped = "skipped" ) // prRepairCandidate is one done change with a PR and the archive interior its @@ -158,12 +162,18 @@ func prRepairCandidates(snap domain.Snapshot, link render.LinkContext) ([]prRepa // planPRRepairs reads the candidates' PR bodies in ≤25-number batches and keeps // the ones whose block does not name the archive path. A failed batch, an // unresolved slot, or malformed markers is an `unreadable` row, never a silent drop. +// A PR named by more than one candidate is a `skipped` row for each of them: +// its backlink has no single owner, so it is never read or edited. func planPRRepairs(ctx context.Context, gh RepairGitHub, repo githubcli.Repository, cands []prRepairCandidate) ([]prRepairPlanned, []PRBacklinkRepair) { byPR := map[int][]prRepairCandidate{} var numbers []int for _, c := range cands { byPR[c.pr] = append(byPR[c.pr], c) - numbers = append(numbers, c.pr) + } + for n, cs := range byPR { + if len(cs) == 1 { + numbers = append(numbers, n) + } } read := map[int]githubcli.BatchPRResult{} failMsg := map[int]string{} @@ -183,6 +193,11 @@ func planPRRepairs(ctx context.Context, gh RepairGitHub, repo githubcli.Reposito var planned []prRepairPlanned var skipped []PRBacklinkRepair for _, c := range cands { + if sharers := byPR[c.pr]; len(sharers) > 1 { + skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairSkipped, + Message: "the pull request is referenced by more than one change (" + prRepairSharerIDs(sharers) + "); it is left untouched"}) + continue + } br, ok := read[c.pr] if !ok { skipped = append(skipped, PRBacklinkRepair{ID: c.id, PR: c.pr, Outcome: PRBacklinkRepairUnreadable, Message: failMsg[c.pr]}) @@ -208,6 +223,15 @@ func planPRRepairs(ctx context.Context, gh RepairGitHub, repo githubcli.Reposito return planned, skipped } +// prRepairSharerIDs lists the ids of the candidates sharing one PR. +func prRepairSharerIDs(cs []prRepairCandidate) string { + ids := make([]string, 0, len(cs)) + for _, c := range cs { + ids = append(ids, fmt.Sprintf("%04d", c.id)) + } + return strings.Join(ids, ", ") +} + func prRepairRows(planned []prRepairPlanned, skipped []PRBacklinkRepair) []PRBacklinkRepair { rows := make([]PRBacklinkRepair, 0, len(planned)+len(skipped)) for _, p := range planned { diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 24e2c7da3..8c17a139f 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:f8b99495a63fbd9e01bb0ffb0638efa3ccfff2ae3052d7773a2b3e8ad523a2c2", + "asset_set_id": "sha256:ee29ff00a74f9f875c35fcb72b4ab9ca4e1f93d383163a2efbaa95d9ceb60a3d", "entries": [ { "path": ".docket.example.yml", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 50880, - "sha256": "c9697c2b86294eb5155622d9dd42eb3ab0558168f6fb2c5851c873fe68982ddd" + "size": 51082, + "sha256": "8cd881b6e1046637b8a73dd4113f78a5fa75a4f4eb480959b7c9ebc55c18bd77" }, { "path": "skills/docket-convention/references/agent-layer.md", @@ -357,8 +357,8 @@ "path": "skills/docket-finalize-change/SKILL.md", "role": "skill", "mode": 420, - "size": 41505, - "sha256": "a3fde354c1d5106ffeb35425c61c2025e6cf0f4e5d2fe6ba1617a472cc36b389" + "size": 41614, + "sha256": "e09de679804ee2341eff76c6803a620e4577c52e1d5717dac4b943c21e3b7e1c" }, { "path": "skills/docket-finalize-change/references/gate-failure.md", diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index 82b0c563c..01aacd46d 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -215,7 +215,7 @@ change, never in the merged artifact. `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, written solely by the `artifact.backlink` operation (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer: `artifact.backlink` writes the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. diff --git a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md index b691dce24..9e0d9bdd5 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md @@ -158,9 +158,9 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, and the merged PR description's backlink repointed to the archived record, validated, committed by explicit path, lease-pushed. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. -- `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. +- `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. The same follow-up then repoints the root's and each descendant's PR. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). - `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. diff --git a/internal/githubcli/bodyedit.go b/internal/githubcli/bodyedit.go index eb0649652..5a1fd302f 100644 --- a/internal/githubcli/bodyedit.go +++ b/internal/githubcli/bodyedit.go @@ -14,8 +14,11 @@ package githubcli // 4. `gh pr edit --repo --body-file -` carries the body on stdin // only — never in argv or a diagnostic — and edits nothing else; and // 5. a fresh by-number probe re-derives the postcondition: an equal body is -// `edited`, a different body is `contended` (a race, reported, never rolled -// back), and a probe that cannot be established is `unknown`. +// `edited`; a body still equal to the pre-edit body means the edit did not +// land (refused, locked, or lost) and is `unknown`, carrying a redacted +// stderr excerpt when gh exited non-zero; a third body is `contended` (a +// race, reported, never rolled back); and a probe that cannot be +// established is `unknown`. // // An errored probe is never read as clean absence (learning // probe-error-is-not-clean-absence): a launch/timeout/cancel Failure or a @@ -38,7 +41,8 @@ const ( // BodyAlready: the PR already carried exactly the requested body; no edit. BodyAlready BodyEditOutcome = "already" // BodyContended: the live revision diverged from ExpectedRevision, or the - // verified body differs from the request; the PR was not overwritten. + // verified body is neither the request nor the pre-edit body (someone else + // wrote it); the PR was not overwritten. BodyContended BodyEditOutcome = "contended" // BodyUnknown: an external probe could not establish the truth (or the input // was invalid); nothing was authorized. The returned error is the diagnostic. @@ -67,7 +71,7 @@ func (c *Client) EditPullRequestBody(ctx context.Context, repo Repository, numbe return BodyContended, PullRequest{}, nil } - _, mf := c.run(ctx, runRequest{ + res, mf := c.run(ctx, runRequest{ op: bodyEditOp, args: []string{ "pr", "edit", strconv.Itoa(number), @@ -91,5 +95,27 @@ func (c *Client) EditPullRequestBody(ctx context.Context, repo Repository, numbe if after.Body == body { return BodyEdited, after, nil } + if after.Body == pr.Body { + // The body never moved: the edit did not land. That is a refusal or a + // lost write, never a race. + return BodyUnknown, PullRequest{}, bodyEditNotApplied(res, mf) + } return BodyContended, PullRequest{}, nil } + +// bodyEditNotApplied is the diagnostic for an edit the verify probe proved did +// not land: the run Failure (timeout/cancel) when there is one, else gh's exit +// with a bounded, redacted stderr excerpt. +func bodyEditNotApplied(res runResult, mf *Failure) *Failure { + if mf != nil { + return newFailure(bodyEditOp, StageInvoke, mf.Kind, "the body edit was not applied: "+mf.Detail, mf) + } + detail := "the body edit was not applied" + if res.exitCode != 0 { + detail = "gh pr edit exited " + strconv.Itoa(res.exitCode) + " and the body edit was not applied" + if ex := stderrExcerpt(res.stderr); ex != "" { + detail += ": " + ex + } + } + return newFailure(bodyEditOp, StageInvoke, KindExternal, detail, nil) +} diff --git a/internal/githubcli/bodyedit_integration_test.go b/internal/githubcli/bodyedit_integration_test.go index df6dcb60a..a339e5660 100644 --- a/internal/githubcli/bodyedit_integration_test.go +++ b/internal/githubcli/bodyedit_integration_test.go @@ -144,6 +144,42 @@ func TestIntegrationMergeBodyEditProbeActVerify(t *testing.T) { } }) + t.Run("refused-edit-is-unknown-not-contended", func(t *testing.T) { + // A refused edit (403/locked) leaves the pre-edit body in place: the verify + // probe must report it as a failed edit with gh's redacted stderr, never as + // a race. + edit := retEditArm(1) + edit.Stderr = "HTTP 403: Resource not accessible by integration (token ghp_abcdefghijklmnopqrstuvwxyz0123456789)\n" + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + edit, + retViewArm(mergedPRJSON(bodyOld), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q with a diagnostic", out, err, BodyUnknown) + } + msg := err.Error() + if !strings.Contains(msg, "not applied") || !strings.Contains(msg, "HTTP 403") { + t.Fatalf("diagnostic %q must say the edit was not applied and carry the stderr excerpt", msg) + } + if strings.Contains(msg, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") { + t.Fatalf("diagnostic leaked a token: %q", msg) + } + }) + + t.Run("zero-exit-but-unchanged-is-unknown", func(t *testing.T) { + c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ + retViewArm(mergedPRJSON(bodyOld), 0), + retEditArm(0), + retViewArm(mergedPRJSON(bodyOld), 0), + }}) + out, _, err := c.EditPullRequestBody(context.Background(), retRepo(), 7, oldRev, bodyNew) + if out != BodyUnknown || err == nil { + t.Fatalf("outcome = %q err=%v, want %q: an unchanged body is no race", out, err, BodyUnknown) + } + }) + t.Run("verify-error-unknown", func(t *testing.T) { c, _ := newFakeClient(t, fakeScenario{Sequential: true, Invocations: []fakeArm{ retViewArm(mergedPRJSON(bodyOld), 0), diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 9907ee953..2759e1651 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,14 +196,14 @@ var skillBudgets = []skillBudget{ {"docket-build/references/gate-driver.md", 138, 1552}, // 0502 review: the raw-verb section names finalize's repaired-head re-gate (137/1546 -> 138/1552); 0502: "loop" means /loop only (137/1550 -> 137/1546); 0502: finalize's repaired-head re-gate is a direct caller (136/1523 -> 137/1550); 0502: the gate-execution intro reference is deleted (138/1555 -> 136/1523); 0502: renamed from gate-caller-loop.md; 0497: +halted-gate tree-survives finding relay (134/1510 -> 138/1555); 0491: the run id is retired (134/1513 -> 134/1510); 0490: the admission section describes the supervisor-held worktree lock; launch-unconfirmed is no longer a refusal (136/1511 -> 134/1513); 0488 review fix: finalize reaches the driver only through finalize.rebase; task-era handoff clauses dropped (word ceiling 1504 -> 1511); 0488 review: start row carries --change-id (word ceiling 1491 -> 1504); 0488: worker-scope/takeover/acknowledge rows and the parent-takeover section removed; callers are the full-suite gates (175/1872 -> 136/1491); 0467: +prepare-scope --run-id and scope-inherited start run id (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/task-routing.md", 50, 500}, {"docket-build-task/SKILL.md", 168, 1637}, // 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) - {"docket-convention/SKILL.md", 357, 7004}, // 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) + {"docket-convention/SKILL.md", 357, 7026}, // 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. {"docket-convention/references/agent-layer.md", 148, 1419}, // 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) {"docket-convention/references/close-out.md", 141, 1349}, // 0502: "loop" means /loop only (144/1375 -> 144/1374); 0502: one metadata layout — the docket branch and the .docket/ worktree (144/1376 -> 144/1375); 0502: the scripts/.md contract pointer becomes the schema operation (144/1374 -> 144/1376); 0502: the terminal-publication step is deleted (240/2150 -> 144/1374); 0474: reference renamed (ADR-0129 row 59a); ceilings unchanged; 0502: drop change citations and Go v1 narration (144/1374 -> 144/1356); 0502: the docket-source test citations are deleted (144/1356 -> 141/1349) {"docket-convention/references/learnings.md", 66, 457}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (66/455 -> 66/457); 0502: learning.record/learning.update are the writers; the capacity section is deleted (84/580 -> 66/455) {"docket-convention/references/stacked-changes.md", 214, 2149}, // 0502: typed operations and binary-emitted findings replace the Bash-era names (213/2125 -> 214/2149); 0502: the deferred descendant-publication sentence is deleted (215/2140 -> 213/2125); 0327: +carry-preservation contract prose (see note above) - {"docket-finalize-change/SKILL.md", 239, 5984}, // 0529: close-out repoints the merged PR description's backlink; pr-backlink-pending is a retryable warning (239/5936 -> 239/5984); 0525 review: workspace-remnant rides any finished-workspace disposition and is named in the final report; branch-rules-unavailable comes only from the probing run (239/5910 -> 239/5936); 0525: plan-gated merges carry branch-rules-unavailable into closeout notes; cleanup reports workspace-remnant and names what blocked a workspace (239/5820 -> 239/5910); 0515 review: the refused-notes fallback names every dropped note in the final report (238/5809 -> 239/5820); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (238/5708 -> 238/5809); 0515: the repair sign-off is retired; a green repair merges (238/5808 -> 238/5708); 0517: the repaired-head evidence.record carries --owner finalize and records finalize.test_command (238/5790 -> 238/5808); 0502 review: clear-block re-reads the revision after the block lands (238/5771 -> 238/5790); 0502: repair sign-off records finalize.block before publishing the repaired head (238/5733 -> 238/5771); 0502: "loop" means /loop only (238/5729 -> 238/5733); 0502: repair sign-off is the human running finalize.clear-block (238/5666 -> 238/5729); 0502: the repaired head is re-gated through the gate driver (236/5568 -> 238/5666); 0502: the dummy-mode paragraph is deleted (238/5641 -> 236/5568); 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) + {"docket-finalize-change/SKILL.md", 239, 6000}, // 0529 review: the PR-backlink repoint is a best-effort follow-up after the archive push, for root-archived descendants too (239/5984 -> 239/6000); 0529: close-out repoints the merged PR description's backlink; pr-backlink-pending is a retryable warning (239/5936 -> 239/5984); 0525 review: workspace-remnant rides any finished-workspace disposition and is named in the final report; branch-rules-unavailable comes only from the probing run (239/5910 -> 239/5936); 0525: plan-gated merges carry branch-rules-unavailable into closeout notes; cleanup reports workspace-remnant and names what blocked a workspace (239/5820 -> 239/5910); 0515 review: the refused-notes fallback names every dropped note in the final report (238/5809 -> 239/5820); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (238/5708 -> 238/5809); 0515: the repair sign-off is retired; a green repair merges (238/5808 -> 238/5708); 0517: the repaired-head evidence.record carries --owner finalize and records finalize.test_command (238/5790 -> 238/5808); 0502 review: clear-block re-reads the revision after the block lands (238/5771 -> 238/5790); 0502: repair sign-off records finalize.block before publishing the repaired head (238/5733 -> 238/5771); 0502: "loop" means /loop only (238/5729 -> 238/5733); 0502: repair sign-off is the human running finalize.clear-block (238/5666 -> 238/5729); 0502: the repaired head is re-gated through the gate driver (236/5568 -> 238/5666); 0502: the dummy-mode paragraph is deleted (238/5641 -> 236/5568); 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 161, 2109}, // 0515 review: the refused-notes fallback names every dropped note in the final report (160/2098 -> 161/2109); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (153/2005 -> 160/2098); 0515: the repair sign-off is retired; a green repair merges (160/2086 -> 153/2005); 0517: the repaired-head evidence.record carries --owner finalize (159/2081 -> 160/2086); 0502 review: clear-block re-reads the revision after the block lands (157/2062 -> 159/2081); 0502: repair sign-off records finalize.block before publishing the repaired head (155/2028 -> 157/2062); 0502: the embedded-bundle rule is conditioned on docket's own repository (155/2024 -> 155/2028); 0502: "loop" means /loop only (155/2027 -> 155/2024); 0502: repair sign-off is the human running finalize.clear-block; the marker lifecycle names what removes it (154/2013 -> 155/2027); 0502: the repaired head is re-gated through the gate driver (148/1937 -> 154/2013); 0497: +wait-for-the-leftover-group sentence (146/1901 -> 148/1937); 0493: the gate relaunch is retired (145/1892 -> 146/1901); 0491: the run id is retired (145/1894 -> 145/1892); 0490: the shared-slot note became the shared worktree-lock note (147/1901 -> 145/1894); 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) {"docket-groom-next/SKILL.md", 76, 2102}, // 0502 rebase onto 0509: carried the edit-a-stub revise route and groom-exit-scoped STOPs (76/2012 -> 76/2102); 0502: "loop" means /loop only (76/2011 -> 76/2012); 0502: one metadata layout — the docket branch and the .docket/ worktree (76/2010 -> 76/2011); 0502: selection bands key on auto_groomable itself (76/2009 -> 76/2010); 0502: invoke superpowers:brainstorming by name (76/2037 -> 76/2009); 0502: the dummy-mode pointer is deleted (78/2080 -> 76/2037); 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) {"docket-implement-next/SKILL.md", 216, 8443}, // 0517: evidence.record reads the gate command from build configuration (216/8438 -> 216/8443); 0502 rebase onto 0510: carried the Step 6.5 Backlog match paragraph and verdict pointers (214/8039 -> 216/8438); 0502 review: the controller dispatches the review tier wrapper; the skill dispatches nothing (214/8023 -> 214/8039); 0502: "loop" means /loop only (214/8058 -> 214/8057); 0502: one metadata layout — the docket branch and the .docket/ worktree (214/8042 -> 214/8058); 0502: the review payload names the resolved build.gate value; build_gate spellings become build.gate (214/8020 -> 214/8042); 0502: change.reclaim replaces reclaim-claims; the claim removes a leftover ## Run halted (214/8009 -> 214/8020); 0502: terminal-publication narration is deleted (214/8037 -> 214/8009); 0502: the severity floor is read as review.min_fix_severity through diagnostic.config (214/8034 -> 214/8037); 0502: the fixed role skills replace the $SKILL_* sites (214/8257 -> 214/8034); 0502: the dummy-mode paragraph and PR-body clause are deleted (216/8356 -> 214/8257); 0497: +Step 5 halted-build evidence kept verbatim in ## Run halted (216/8325 -> 216/8356); 0498 review: checkpoint (ii) persist wording (8320 -> 8325); 0498: +Step 6.5 review-findings homes, condensation exception, Human-actions class rule (214/8161 -> 216/8320); 0491: the run id is retired (214/8183 -> 214/8161); 0488 review: build-owned starts carry --change-id/--run-context (word ceiling 8162 -> 8183); 0488: run context and run id name only build-owned starts (word ceiling 8175 -> 8162); 0467 review fix: +the repair worker's post-fix re-run is a build-owned start (word ceiling 8165 -> 8175); 0467: +run id threaded to prepare-scope and build-owned starts (word ceiling 8080 -> 8165); 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +run-tracker resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose; 0502: drop change citations and Go v1 narration (214/8057 -> 214/8023) diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index 82b0c563c..01aacd46d 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -215,7 +215,7 @@ change, never in the merged artifact. `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, written solely by the `artifact.backlink` operation (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer: `artifact.backlink` writes the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. diff --git a/skills/docket-finalize-change/SKILL.md b/skills/docket-finalize-change/SKILL.md index b691dce24..9e0d9bdd5 100644 --- a/skills/docket-finalize-change/SKILL.md +++ b/skills/docket-finalize-change/SKILL.md @@ -158,9 +158,9 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, and the merged PR description's backlink repointed to the archived record, validated, committed by explicit path, lease-pushed. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. -- `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. +- `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. The same follow-up then repoints the root's and each descendant's PR. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). - `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. From df03be7e37224f318dbfef1069f494779fd68c11 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 21:45:23 -0400 Subject: [PATCH 13/13] docs(results): change 0529 review and final consolidation --- ...ge-backlink-when-the-change-is-arc-results.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md b/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md index 5b223629e..60a6ed3d4 100644 --- a/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md +++ b/docs/results/2026-10-05-repoint-a-merged-pr-s-change-backlink-when-the-change-is-arc-results.md @@ -3,7 +3,7 @@ # Repoint a merged PR's change backlink when the change is archived — Results -**Human action:** After merge, one human step is needed: preview and then run the one-time `docket repository repair --pr-backlinks` repair to fix the PRs whose change link is already dead. Nothing else needs a human before merge. +**Human action:** Before merging, know that the first full maintenance sweep after merge will repoint about 168 already-merged PR descriptions on its own, with no preview. If you want to review them first, run the `docket repository repair --pr-backlinks` preview right after merge, before any full sweep. ## Outcome @@ -11,19 +11,20 @@ Before this change, every PR docket opened linked to its change record at `docs/ Now, when close-out archives a merged change, it rewrites only the docket-owned backlink block in that PR's description so it points at the archived record. Authored text in the description is left byte-for-byte as it was. A PR with no backlink block (written by hand) is left alone. The edit is best effort: if GitHub refuses or the result is unknown, close-out still succeeds and reports a pending warning, and the next cleanup or full maintenance sweep retries it. -A new, explicit `docket repository repair --pr-backlinks` lists every merged PR whose backlink still names a path that no longer exists, with the current and corrected link, and with `--yes` fixes them one at a time. A PR that can't be read or written is reported and skipped. Plain `repository repair`, `repository check`, and `repository prepare` still never read PR descriptions. +The full maintenance sweep applies the same retry to every archived change whose PR backlink still names a stale `active/` path, so it also fixes PRs merged before this change, without a preview. A new, explicit `docket repository repair --pr-backlinks` lists every merged PR whose backlink still names a path that no longer exists, with the current and corrected link, and with `--yes` fixes them one at a time. A PR that can't be read or written is reported and skipped. Plain `repository repair`, `repository check`, and `repository prepare` still never read PR descriptions. Departures from the spec: - `finalize.closeout` now declares the `external-write` effect (it edits GitHub). The spec only named this for `repository.repair`. - `--pr-backlinks` runs only the PR repair, not the usual metadata repairs; plain `repository repair` still does those. +- The full maintenance sweep's retry also covers legacy PRs, so it does the bulk repair itself; `repository repair --pr-backlinks` is the previewed way to do it all at once. - Every full-scope maintenance sweep now reads the descriptions of done changes' PRs in batches of 25 (roughly ceil(done/25) GitHub GraphQL calls, about 10 for this repository today). The implementation-scope sweep reads none. ## Human actions and testing -### Important — run the one-time PR backlink repair after merge +### Important — decide how the ~168 legacy PR links get fixed -About 168 already-merged PRs in this repository link to a change record that no longer exists. Only the repair fixes them; close-out only handles changes archived from now on. If skipped, those links stay dead. +About 168 already-merged PRs link to a change record that no longer exists. The first full maintenance sweep after merge (for example a `docket-status` refresh) will edit all of them automatically. If you would rather preview the list first, do the steps below right after merge and before any full sweep; if you are happy with the automatic fix, you can skip them. Prerequisites: this change merged and the installed `docket` binary rebuilt from `main`; `gh` authenticated with write access to the repository. @@ -48,6 +49,9 @@ The automated tests use a fake GitHub. After merge, the next finalized change's - A read-only preview of `repository repair --pr-backlinks` against this repository planned 168 PRs, 0 unreadable; `--yes` was not run. - Bundle regeneration was checked with `genassets -check`; the living-docs, comment-anchor and skill-size guards pass. +- Whole-branch review (deep tier): 6 findings (1 blocker, 1 important, 4 minor), all fixed in-branch; full table in the PR body. +- The final full-suite gate passed; its budget report carried screening-only `PARALLEL-SENSITIVE` / `BUDGET WATCH` lines (finalize e2e, app merge, race tests) and no serial-confirmed breach. + ## Known issues and follow-ups ### GitHub may normalize an edited description @@ -58,6 +62,10 @@ When docket edits a PR description it re-reads it to confirm. If GitHub stores t Every full-scope maintenance sweep reads done changes' PR descriptions in batches, about 10 extra GraphQL calls today and growing with the archive. This is by design so the sweep can retry failed repoints. If it becomes a cost problem, the sweep could skip records already confirmed repointed. +### The first full sweep edits many PRs at once + +Because the sweep retries any archived change with a stale link, its first run after merge makes about 168 cleanup passes and GitHub edits. This was a review blocker, resolved by keeping the spec's sweep rule and documenting it. Each edit only rewrites the docket-owned link block. To control timing, run the repair preview first (see Human actions). + ### Older PRs without a backlink block are not touched The preview found 168 PRs to fix rather than the roughly 250 estimated at grooming. Older merged PRs that predate the backlink block have nothing to repoint and are deliberately left alone, matching the spec.