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.
Let a change request gain its issue links after it is opened
bdf6a0de1ad493c9c4650a27c926767b06621a70 · Keenan Nemetz · 14d ago · parent 183d48e5
Let a change request gain its issue links after it is opened A link is usually discovered after the request exists: the issue turns up while reviewing, or while writing the landing note. Without an edit path the only options were to open a second request or leave the issue behind, which is the drift the link was meant to stop. `vole change update <id> --closes <issue>...` replaces the list and `--no-closes` clears it, alongside the existing `--head`, in one command and one metadata commit. An unchanged list is not an edit, so the branch does not move, and the ids are checked against the issues tree at that point as well as at creation — a link added later is exactly where a typo would otherwise hide until the issue quietly never closed. Only an open request may be relinked. A merged request's list records what the merge did, and a closed one is not going to run, so both refuse.
metadata/requests_test.go +67
| @@ -364,3 +364,70 @@ func TestMarkMergedClosesLinkedIssues(t *testing.T) { | |||
| 364 | 364 | t.Fatalf("second mark = %v, %v", again, err) | |
| 365 | 365 | } | |
| 366 | 366 | } | |
| 367 | |||
| 368 | // A link is often discovered after a request is opened, so it can be set later. The | ||
| 369 | // same rules apply: a full id, an issue that exists, and only while the request can | ||
| 370 | // still land. | ||
| 371 | func TestSetRequestClosesEditsAnOpenRequest(t *testing.T) { | ||
| 372 | t.Parallel() | ||
| 373 | root := t.TempDir() | ||
| 374 | if err := git.InitBare(root, "main"); err != nil { | ||
| 375 | t.Fatal(err) | ||
| 376 | } | ||
| 377 | g := git.Open(root) | ||
| 378 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 379 | seed, err := g.CommitTree(mustTree(t, g), "", "seed") | ||
| 380 | if err != nil { | ||
| 381 | t.Fatal(err) | ||
| 382 | } | ||
| 383 | if err := g.UpdateRef(Ref, seed); err != nil { | ||
| 384 | t.Fatal(err) | ||
| 385 | } | ||
| 386 | iss, err := NewIssue("Later", "", "keenan", nil, nil, now) | ||
| 387 | if err != nil { | ||
| 388 | t.Fatal(err) | ||
| 389 | } | ||
| 390 | if err := CreateIssue(g, iss, "keenan"); err != nil { | ||
| 391 | t.Fatal(err) | ||
| 392 | } | ||
| 393 | req, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 394 | if err != nil { | ||
| 395 | t.Fatal(err) | ||
| 396 | } | ||
| 397 | if err := CreateRequest(g, req, "keenan"); err != nil { | ||
| 398 | t.Fatal(err) | ||
| 399 | } | ||
| 400 | |||
| 401 | got, err := SetRequestCloses(g, req.ID, []string{iss.ID}, "keenan") | ||
| 402 | if err != nil { | ||
| 403 | t.Fatalf("link: %v", err) | ||
| 404 | } | ||
| 405 | if len(got.Closes) != 1 || got.Closes[0] != iss.ID { | ||
| 406 | t.Fatalf("closes = %v, want [%s]", got.Closes, iss.ID) | ||
| 407 | } | ||
| 408 | // A second call with the same set is not an edit, so the branch does not move. | ||
| 409 | if _, err := SetRequestCloses(g, req.ID, []string{iss.ID}, "keenan"); err != nil { | ||
| 410 | t.Fatalf("re-link: %v", err) | ||
| 411 | } | ||
| 412 | for _, bad := range [][]string{{"01M5JR2JK29KKMAA1T9MDNTRBZ"}, {"short"}} { | ||
| 413 | if _, err := SetRequestCloses(g, req.ID, bad, "keenan"); err == nil { | ||
| 414 | t.Fatalf("closes %v was accepted", bad) | ||
| 415 | } | ||
| 416 | } | ||
| 417 | // Once merged, the list is a record of what happened, not a setting. | ||
| 418 | if _, _, err := MarkMerged(g, req.ID, headSHA, "reviewer", now); err != nil { | ||
| 419 | t.Fatal(err) | ||
| 420 | } | ||
| 421 | if _, err := SetRequestCloses(g, req.ID, nil, "keenan"); err == nil { | ||
| 422 | t.Fatal("a merged request's links were changed") | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | func mustTree(t *testing.T, g *git.Repo) string { | ||
| 427 | t.Helper() | ||
| 428 | tree, err := g.MkTree(nil) | ||
| 429 | if err != nil { | ||
| 430 | t.Fatal(err) | ||
| 431 | } | ||
| 432 | return tree | ||
| 433 | } | ||