Closes #01M5XG8WYZ1566KZFC73WQWGX3.
Why
A change request and the issue it resolves were two records that knew nothing about each other. Landing a request left its issue open, so the issue tracker drifted from the branch history the moment anybody merged, and the only thing keeping them in step was somebody remembering to close the issue afterwards.
What changed
The link is a field, not prose. request.md gains a closes: list of full issue
ids. That makes it part of the file a reviewer reads, rather than a Closes #abc
convention inside a description that nothing parses. Ids are validated and
deduplicated on read and write, and vole change new --closes <id> refuses an id that
is not an issue — while the author is still looking at the command, not at the landing
they waited on.
The closure happens with the merge, in one commit. metadata.MarkMerged already
writes the merged state; it now writes state: closed onto each linked issue in the
same CommitFiles transaction. So the two cannot be separated by a crash: a merged
request cannot leave its issues open, and an issue cannot be closed by a merge that did
not happen. Both sides are validated afterwards and a failure is reported as
ErrLandedUnmarked, which already carries “the branch moved, the record did not”.
What is deliberately left alone: an issue that is already closed is not rewritten (somebody closing it deliberately is not overruled), an issue that no longer exists is skipped (a request may outlive the issue), and re-marking an already-merged request closes nothing, so a retried landing is harmless.
Surfaces: vole change new --closes <id> (repeatable, comma lists accepted), the
new-change form in the browser, “Closes #…” links on the request page, and
vole change merge printing closed issue <id> for each one it closed.
Verification
vole workflow run verify in the pinned image: gofmt, vet, build, govulncheck
(0 reachable), packaging, links, go test -race ./... — all pass.
New tests:
metadata: thecloseslist is sorted and deduplicated, and a short or malformed id is refused;MarkMergedcloses the linked issue, leaves an unrelated one open, does not rewrite an already-closed one, returns exactly the ids it changed, is a no-op the second time, andCreateRequestrefuses a link to an issue that does not exist;forge/landing: landing a request closes the issue it names and nothing else.
One limitation, stated plainly
The closure happens where the request is marked merged — the server-side landing
(Land), which is now the supported flow. A request landed the old way, by pushing the
approved branch, is not marked by the hook (the hook validates and reports; it does not
write metadata), so its issues stay open until the request is merged. That is the same
gap that kept a push-landed request showing open and “Already merged” — and running
vole change merge on it closes the issues too, because that path re-reads the request
and marks it.
Acceptance
- A change request can name the issues it closes
- Merging closes them, in the same commit as the merged mark
- A bad or unknown issue id is refused when the request is written
- An already-closed or removed issue is left alone
- CLI, browser form, request page and merge output all show the link
- The issue’s own record shows it closed, by the same actor, at the same time