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 | } | ||