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.

forge/landing/land_test.go +58

@@ -88,12 +88,18 @@ func (f *fixture) commit(parent, name, content, msg string) string {
88 88
89 89 // review opens a request for head against main and has reviewer approve it.
90 90 func (f *fixture) review(author, head, reviewer string) string {
91 f.t.Helper()
92 return f.reviewWith(author, head, reviewer, nil)
93 }
94
95 func (f *fixture) reviewWith(author, head, reviewer string, closes []string) string {
91 96 f.t.Helper()
92 97 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
93 98 req, err := metadata.NewRequest("Land it", "why", author, "main", head, now)
94 99 if err != nil {
95 100 f.t.Fatal(err)
96 101 }
102 req.Closes = closes
97 103 if err := metadata.CreateRequest(f.g, req, author); err != nil {
98 104 f.t.Fatal(err)
99 105 }
@@ -375,3 +381,55 @@ func TestPrepareDescribesAMergeCommit(t *testing.T) {
375 381 t.Fatalf("landed %s but the plan said %s", res.NewTip, plan.NewTip)
376 382 }
377 383 }
384
385 // Landing a request closes the issues it says it closes, in the same commit that
386 // marks it merged. The point of the link is that nobody has to remember.
387 func TestLandClosesTheIssuesTheRequestNames(t *testing.T) {
388 t.Parallel()
389 f := newFixture(t)
390 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
391 closing, err := metadata.NewIssue("Blocked by the workflow", "why", "keenan", nil, nil, now)
392 if err != nil {
393 t.Fatal(err)
394 }
395 if err := metadata.CreateIssue(f.g, closing, "keenan"); err != nil {
396 t.Fatal(err)
397 }
398 other, err := metadata.NewIssue("Unrelated", "why", "keenan", nil, nil, now)
399 if err != nil {
400 t.Fatal(err)
401 }
402 if err := metadata.CreateIssue(f.g, other, "keenan"); err != nil {
403 t.Fatal(err)
404 }
405
406 base := f.commit("", "readme.md", "v1\n", "base")
407 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
408 t.Fatal(err)
409 }
410 head := f.commit(base, "readme.md", "v2\n", "change")
411 f.protectMain(1)
412 id := f.reviewWith("keenan", head, "reviewer", []string{closing.ID})
413
414 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
415 if err != nil {
416 t.Fatalf("land: %v", err)
417 }
418 if len(res.ClosedIssues) != 1 || res.ClosedIssues[0] != closing.ID {
419 t.Fatalf("closed = %v, want [%s]", res.ClosedIssues, closing.ID)
420 }
421 issues, err := metadata.LoadIssues(f.g, metadata.Ref)
422 if err != nil {
423 t.Fatal(err)
424 }
425 state := map[string]string{}
426 for _, iss := range issues {
427 state[iss.ID] = iss.State
428 }
429 if state[closing.ID] != "closed" {
430 t.Fatalf("linked issue = %q, want closed", state[closing.ID])
431 }
432 if state[other.ID] != "open" {
433 t.Fatalf("unrelated issue = %q, want open", state[other.ID])
434 }
435 }