vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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 · 13d 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.
cmd/vole/change.go +24−2
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | func changeUsage() string { | |
| 16 | 16 | return `usage: vole change <command> [options] [args] | |
| 17 | 17 | ||
| 18 | new --title <text> --base <branch> [--head <rev>] [-m <text> | -F <file>] | ||
| 18 | new --title <text> --base <branch> [--head <rev>] [--closes <issue>]... [-m <text> | -F <file>] | ||
| 19 | 19 | list [--state open|closed|all] [--format <fmt>] | |
| 20 | 20 | show <id> | |
| 21 | 21 | comment <id> [-m <text> | -F <file>] [--path <file> --line <n> [--side new|old]] [--reply-to <comment-id>] | |
| @@ -46,6 +46,7 @@ func cmdChange(args []string) error { | |||
| 46 | 46 | {long: "title", short: "t"}, | |
| 47 | 47 | {long: "base", short: "b"}, | |
| 48 | 48 | {long: "head", short: "H"}, | |
| 49 | {long: "closes"}, | ||
| 49 | 50 | {long: "message", short: "m"}, | |
| 50 | 51 | {long: "file", short: "F"}, | |
| 51 | 52 | {long: "as"}, | |
| @@ -67,7 +68,7 @@ func cmdChange(args []string) error { | |||
| 67 | 68 | return nil | |
| 68 | 69 | } | |
| 69 | 70 | changeFlags := map[string][]string{ | |
| 70 | "new": {"title", "base", "head", "message", "file", "remote", "no-push"}, | ||
| 71 | "new": {"title", "base", "head", "closes", "message", "file", "remote", "no-push"}, | ||
| 71 | 72 | "comment": {"message", "file", "path", "line", "side", "reply-to", "remote", "no-push"}, | |
| 72 | 73 | "edit-comment": {"message", "file", "remote", "no-push"}, | |
| 73 | 74 | "rm-comment": {"remote", "no-push"}, | |
| @@ -136,6 +137,10 @@ func cmdChange(args []string) error { | |||
| 136 | 137 | if err != nil { | |
| 137 | 138 | return err | |
| 138 | 139 | } | |
| 140 | // A linked issue is a promise that landing this request closes it, so the ids | ||
| 141 | // are checked when the request is written rather than at the merge: a typo | ||
| 142 | // should fail while the author is still looking at the command. | ||
| 143 | r.Closes = closesFlag(f.all("closes")) | ||
| 139 | 144 | reapply := func() error { return metadata.CreateRequest(c.Repo, r, author) } | |
| 140 | 145 | if err := reapply(); err != nil { | |
| 141 | 146 | return err | |
| @@ -144,6 +149,9 @@ func cmdChange(args []string) error { | |||
| 144 | 149 | return err | |
| 145 | 150 | } | |
| 146 | 151 | fmt.Printf("%s\t%s\t%s\t%s\n", r.ID, r.Base, format.ID(r.Head), r.Title) | |
| 152 | for _, id := range r.Closes { | ||
| 153 | fmt.Fprintf(os.Stderr, "closes %s when merged\n", id) | ||
| 154 | } | ||
| 147 | 155 | if u := who.Page("requests", r.ID, ""); u != "" { | |
| 148 | 156 | fmt.Fprintf(os.Stderr, "opened at %s\n", u) | |
| 149 | 157 | } | |
| @@ -427,3 +435,17 @@ func cmdChange(args []string) error { | |||
| 427 | 435 | fmt.Fprintln(os.Stderr, changeUsage()) | |
| 428 | 436 | return usagef("unknown change subcommand %q", sub) | |
| 429 | 437 | } | |
| 438 | |||
| 439 | // closesFlag normalises --closes, which is repeatable and may also carry a | ||
| 440 | // comma-separated list: both habits are common and neither is worth a second flag. | ||
| 441 | func closesFlag(values []string) []string { | ||
| 442 | var out []string | ||
| 443 | for _, v := range values { | ||
| 444 | for _, id := range strings.Split(v, ",") { | ||
| 445 | if id = strings.TrimSpace(id); id != "" { | ||
| 446 | out = append(out, id) | ||
| 447 | } | ||
| 448 | } | ||
| 449 | } | ||
| 450 | return out | ||
| 451 | } | ||