Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Close linked issues when a change request lands

183d48e55e14b0355db4268275893274433ce8ee · Keenan Nemetz · 14d ago · parent 097b2110

Close linked issues when a change request lands

A change request can now name the issues it closes. Landing writes closed
onto each of them in the same commit that marks the request merged, so the
merge and the closure cannot be separated: a request cannot be merged with
its issues still open, and an issue cannot be closed by a merge that did
not happen.

The link lives in request.md as a list of full issue ids, which is what
makes it reviewable: it is part of the file a reviewer reads, not a
reference buried in prose. Ids are validated when the request is written,
including that the issue exists, so a typo fails while the author is still
looking at the command instead of at the landing they have waited for.

`vole change new --closes <id>` (repeatable, comma lists accepted) sets it,
`vole change merge` reports what it closed, and the browser form takes the
same field. The request page links each issue, and the merge answer names
the ones it closed.

An issue that is already closed, or that is no longer there, is left alone:
the promise is about what a merge does, not permission to overrule somebody
who closed it deliberately. Re-marking an already-merged request closes
nothing, so a retried landing is harmless.

Closes #01M5XG8WYZ1566KZFC73WQWGX3.

metadata/requests_test.go +117

@@ -247,3 +247,120 @@ func TestMergedIsNotAGenericState(t *testing.T) {
247 247 t.Fatal("SetRequestState accepted merged")
248 248 }
249 249 }
250
251 // A request may name the issues it closes. The file is the record, so the ids have to
252 // be full ones — a prefix that collides is exactly the mistake this format refuses.
253 func TestClosesIsAFullIdList(t *testing.T) {
254 t.Parallel()
255 body := func(closes string) map[string]string {
256 return map[string]string{
257 RequestsDir + "/" + reqIDA + "/request.md": "---\n" +
258 `title: "Land the retry"` + "\n" +
259 "state: open\n" +
260 "author: keenan\n" +
261 "base: main\n" +
262 "head: " + headSHA + "\n" +
263 closes +
264 "created: 2026-10-22T22:00:00Z\n" +
265 "---\n",
266 }
267 }
268 const issueA = "01M5JR2JK29KKMAA1T9MDNTRB5"
269 const issueB = "01M5JR2JK29KKMAA1T9MDNTRB6"
270
271 list, err := ParseRequests(body("closes:\n - " + issueB + "\n - " + issueA + "\n - " + issueA + "\n"))
272 if err != nil {
273 t.Fatalf("unexpected error: %v", err)
274 }
275 // Sorted and deduplicated, so two writers that link the same issues render the
276 // same file.
277 if got := strings.Join(list[0].Closes, ","); got != issueA+","+issueB {
278 t.Fatalf("closes = %s, want %s,%s", got, issueA, issueB)
279 }
280 for _, bad := range []string{" - 01M5JR2\n", " - not-an-id\n"} {
281 if _, err := ParseRequests(body("closes:\n" + bad)); err == nil {
282 t.Fatalf("closes entry %q was accepted", strings.TrimSpace(bad))
283 }
284 }
285 }
286
287 // The mark and the closures are one commit: a merge that cannot write both writes
288 // neither, and an issue somebody else already closed is left alone.
289 func TestMarkMergedClosesLinkedIssues(t *testing.T) {
290 t.Parallel()
291 root := t.TempDir()
292 if err := git.InitBare(root, "main"); err != nil {
293 t.Fatal(err)
294 }
295 g := git.Open(root)
296 tree, err := g.MkTree(nil)
297 if err != nil {
298 t.Fatal(err)
299 }
300 seed, err := g.CommitTree(tree, "", "seed")
301 if err != nil {
302 t.Fatal(err)
303 }
304 if err := g.UpdateRef(Ref, seed); err != nil {
305 t.Fatal(err)
306 }
307 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
308 linked, err := NewIssue("Linked", "", "keenan", nil, nil, now)
309 if err != nil {
310 t.Fatal(err)
311 }
312 if err := CreateIssue(g, linked, "keenan"); err != nil {
313 t.Fatal(err)
314 }
315 shut, err := NewIssue("Already closed", "", "keenan", nil, nil, now)
316 if err != nil {
317 t.Fatal(err)
318 }
319 if err := CreateIssue(g, shut, "keenan"); err != nil {
320 t.Fatal(err)
321 }
322 if _, err := SetIssueState(g, shut.ID, "closed", "keenan"); err != nil {
323 t.Fatal(err)
324 }
325 req, err := NewRequest("Land it", "", "keenan", "main", headSHA, now)
326 if err != nil {
327 t.Fatal(err)
328 }
329 req.Closes = []string{linked.ID, shut.ID}
330 if err := CreateRequest(g, req, "keenan"); err != nil {
331 t.Fatal(err)
332 }
333 // A link to an issue that does not exist is refused when the request is written.
334 typo, err := NewRequest("Typo", "", "keenan", "main", headSHA, now)
335 if err != nil {
336 t.Fatal(err)
337 }
338 typo.Closes = []string{"01M5JR2JK29KKMAA1T9MDNTRBZ"}
339 if err := CreateRequest(g, typo, "keenan"); err == nil {
340 t.Fatal("a request closing a non-existent issue was written")
341 }
342
343 got, closed, err := MarkMerged(g, req.ID, headSHA, "reviewer", now)
344 if err != nil {
345 t.Fatalf("mark: %v", err)
346 }
347 if got.State != "merged" || got.MergedHead != headSHA {
348 t.Fatalf("request = %q/%q", got.State, got.MergedHead)
349 }
350 if len(closed) != 1 || closed[0] != linked.ID {
351 t.Fatalf("closed = %v, want [%s] — the already-closed issue is not rewritten", closed, linked.ID)
352 }
353 issues, err := LoadIssues(g, Ref)
354 if err != nil {
355 t.Fatal(err)
356 }
357 for _, iss := range issues {
358 if iss.ID == linked.ID && iss.State != "closed" {
359 t.Fatalf("linked issue = %q, want closed", iss.State)
360 }
361 }
362 // Marking again is a no-op, so a retried landing does not re-close anything.
363 if _, again, err := MarkMerged(g, req.ID, headSHA, "reviewer", now); err != nil || len(again) != 0 {
364 t.Fatalf("second mark = %v, %v", again, err)
365 }
366 }