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.
cmd/vole/change.go +56−8
| @@ -3,6 +3,7 @@ package main | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | "sort" | ||
| 6 | 7 | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "time" | |
| @@ -21,7 +22,7 @@ func changeUsage() string { | |||
| 21 | 22 | comment <id> [-m <text> | -F <file>] [--path <file> --line <n> [--side new|old]] [--reply-to <comment-id>] | |
| 22 | 23 | edit-comment <id> <comment-id> [-m <text> | -F <file>] | |
| 23 | 24 | rm-comment <id> <comment-id> | |
| 24 | update <id> [--head <rev>] | ||
| 25 | update <id> [--head <rev>] [--closes <issue>]... [--no-closes] | ||
| 25 | 26 | approve <id> [--as <account>] | |
| 26 | 27 | merge <id> | |
| 27 | 28 | close|reopen <id> | |
| @@ -58,6 +59,7 @@ func cmdChange(args []string) error { | |||
| 58 | 59 | {long: "remote"}, | |
| 59 | 60 | {long: "format"}, | |
| 60 | 61 | {long: "no-push", boolean: true}, | |
| 62 | {long: "no-closes", boolean: true}, | ||
| 61 | 63 | } | |
| 62 | 64 | f, err := parseFlags(args[1:], specs...) | |
| 63 | 65 | if err != nil { | |
| @@ -74,7 +76,7 @@ func cmdChange(args []string) error { | |||
| 74 | 76 | "rm-comment": {"remote", "no-push"}, | |
| 75 | 77 | "list": {"state", "format", "remote"}, | |
| 76 | 78 | "show": {"remote"}, | |
| 77 | "update": {"head", "remote", "no-push"}, | ||
| 79 | "update": {"head", "closes", "no-closes", "remote", "no-push"}, | ||
| 78 | 80 | "approve": {"as", "remote", "no-push"}, | |
| 79 | 81 | "merge": {"remote"}, | |
| 80 | 82 | "close": {"remote", "no-push"}, | |
| @@ -335,33 +337,61 @@ func cmdChange(args []string) error { | |||
| 335 | 337 | ||
| 336 | 338 | case "update": | |
| 337 | 339 | if len(f.rest) != 1 { | |
| 338 | return usagef("usage: vole change update <id> [--head <rev>]") | ||
| 340 | return usagef("usage: vole change update <id> [--head <rev>] [--closes <issue>]... [--no-closes]") | ||
| 339 | 341 | } | |
| 340 | 342 | r, err := metadata.FindRequest(c.Repo, f.rest[0]) | |
| 341 | 343 | if err != nil { | |
| 342 | 344 | return err | |
| 343 | 345 | } | |
| 344 | 346 | head := r.Head | |
| 347 | headMoved := false | ||
| 345 | 348 | if v := f.one("head"); v != "" { | |
| 346 | 349 | head, err = client.ResolveHead(c, v) | |
| 347 | 350 | if err != nil { | |
| 348 | 351 | return err | |
| 349 | 352 | } | |
| 350 | } | ||
| 351 | if head == r.Head { | ||
| 352 | return usagef("nothing to update: pass --head <rev>") | ||
| 353 | headMoved = head != r.Head | ||
| 354 | } | ||
| 355 | // A link can be added or corrected long after a request was opened, which is | ||
| 356 | // the case that made prose references useless: the issue is discovered later. | ||
| 357 | linksChanged := false | ||
| 358 | closes := r.Closes | ||
| 359 | switch { | ||
| 360 | case f.has("no-closes") && f.given("closes"): | ||
| 361 | return usagef("--closes and --no-closes say different things") | ||
| 362 | case f.has("no-closes"): | ||
| 363 | closes, linksChanged = nil, len(r.Closes) > 0 | ||
| 364 | case f.given("closes"): | ||
| 365 | closes = closesFlag(f.all("closes")) | ||
| 366 | linksChanged = !equalIDs(closes, r.Closes) | ||
| 367 | } | ||
| 368 | if !headMoved && !linksChanged { | ||
| 369 | return usagef("nothing to update: pass --head <rev> or --closes <issue>") | ||
| 353 | 370 | } | |
| 354 | 371 | var out *metadata.Request | |
| 355 | 372 | reapply := func() error { | |
| 356 | 373 | var uerr error | |
| 357 | out, uerr = metadata.SetRequestHead(c.Repo, r.ID, head, author) | ||
| 374 | out = r | ||
| 375 | if linksChanged { | ||
| 376 | if out, uerr = metadata.SetRequestCloses(c.Repo, r.ID, closes, author); uerr != nil { | ||
| 377 | return uerr | ||
| 378 | } | ||
| 379 | } | ||
| 380 | if headMoved { | ||
| 381 | out, uerr = metadata.SetRequestHead(c.Repo, r.ID, head, author) | ||
| 382 | } | ||
| 358 | 383 | return uerr | |
| 359 | 384 | } | |
| 360 | 385 | if err := reapply(); err != nil { | |
| 361 | 386 | return err | |
| 362 | 387 | } | |
| 363 | 388 | fmt.Printf("%s\t%s\t%s\n", out.ID, format.ID(out.Head), out.Title) | |
| 364 | fmt.Fprintf(os.Stderr, "note: existing approvals named the old head and no longer apply\n") | ||
| 389 | for _, id := range out.Closes { | ||
| 390 | fmt.Fprintf(os.Stderr, "closes %s when merged\n", id) | ||
| 391 | } | ||
| 392 | if headMoved { | ||
| 393 | fmt.Fprintf(os.Stderr, "note: existing approvals named the old head and no longer apply\n") | ||
| 394 | } | ||
| 365 | 395 | return c.CommitAndPush(noPush, reapply) | |
| 366 | 396 | ||
| 367 | 397 | case "merge": | |
| @@ -449,3 +479,21 @@ func closesFlag(values []string) []string { | |||
| 449 | 479 | } | |
| 450 | 480 | return out | |
| 451 | 481 | } | |
| 482 | |||
| 483 | // equalIDs compares two id lists as sets after normalisation, which is what deciding | ||
| 484 | // "did the links change" needs: order and duplicates are not edits. | ||
| 485 | func equalIDs(a, b []string) bool { | ||
| 486 | norm := func(in []string) string { | ||
| 487 | seen := map[string]bool{} | ||
| 488 | out := make([]string, 0, len(in)) | ||
| 489 | for _, v := range in { | ||
| 490 | if v = strings.TrimSpace(v); v != "" && !seen[v] { | ||
| 491 | seen[v] = true | ||
| 492 | out = append(out, v) | ||
| 493 | } | ||
| 494 | } | ||
| 495 | sort.Strings(out) | ||
| 496 | return strings.Join(out, ",") | ||
| 497 | } | ||
| 498 | return norm(a) == norm(b) | ||
| 499 | } | ||