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.go +55−14
| @@ -14,9 +14,11 @@ | |||
| 14 | 14 | // afterwards. If the head changes, the approvals no longer match and the gate | |
| 15 | 15 | // closes again. | |
| 16 | 16 | // | |
| 17 | // Merging is a push. The pre-receive hook permits a push to a protected branch | ||
| 18 | // when it carries an approved request, so there is no merge button and no | ||
| 19 | // authenticated write surface to build. | ||
| 17 | // Landing is either a push the pre-receive hook accepts — it permits a protected | ||
| 18 | // branch when the push carries an approved request — or the server's own merge | ||
| 19 | // (forge/landing), which asks the same gate before it moves the ref. A request names | ||
| 20 | // the issues it closes, and landing closes them in the same commit that records the | ||
| 21 | // merge, so a merged request and a closed issue cannot disagree. | ||
| 20 | 22 | package metadata | |
| 21 | 23 | ||
| 22 | 24 | import ( | |
| @@ -27,6 +29,7 @@ import ( | |||
| 27 | 29 | "time" | |
| 28 | 30 | ||
| 29 | 31 | "github.com/rodent-software/vole/core/git" | |
| 32 | voleid "github.com/rodent-software/vole/core/id" | ||
| 30 | 33 | ) | |
| 31 | 34 | ||
| 32 | 35 | const ( | |
| @@ -58,8 +61,13 @@ type Request struct { | |||
| 58 | 61 | MergedHead string | |
| 59 | 62 | MergedBy string // account that ran the merge | |
| 60 | 63 | MergedAt time.Time | |
| 61 | Approvals []Approval | ||
| 62 | Comments []Comment | ||
| 64 | // Closes are the issues this request is a promise to close. Landing writes | ||
| 65 | // closed onto each of them in the same commit that marks the request merged, so | ||
| 66 | // a request cannot be merged with its issues still open, and an issue cannot be | ||
| 67 | // closed by a merge that did not happen. | ||
| 68 | Closes []string | ||
| 69 | Approvals []Approval | ||
| 70 | Comments []Comment | ||
| 63 | 71 | } | |
| 64 | 72 | ||
| 65 | 73 | // Comment is a remark on a request. It is the same file shape as a comment on an | |
| @@ -91,15 +99,16 @@ func (r *Request) ApprovedBy(principal string) bool { | |||
| 91 | 99 | // meta is the frontmatter head of request.md. Field names are part of the | |
| 92 | 100 | // public format. There is no updated field: the commit carries the time. | |
| 93 | 101 | type requestMeta struct { | |
| 94 | Title string `yaml:"title"` | ||
| 95 | Author string `yaml:"author"` | ||
| 96 | Base string `yaml:"base"` | ||
| 97 | Head string `yaml:"head"` | ||
| 98 | State string `yaml:"state"` | ||
| 99 | Created string `yaml:"created"` | ||
| 100 | MergedHead string `yaml:"merged_head"` | ||
| 101 | MergedBy string `yaml:"merged_by"` | ||
| 102 | MergedAt string `yaml:"merged_at"` | ||
| 102 | Title string `yaml:"title"` | ||
| 103 | Author string `yaml:"author"` | ||
| 104 | Base string `yaml:"base"` | ||
| 105 | Head string `yaml:"head"` | ||
| 106 | State string `yaml:"state"` | ||
| 107 | Closes []string `yaml:"closes"` | ||
| 108 | Created string `yaml:"created"` | ||
| 109 | MergedHead string `yaml:"merged_head"` | ||
| 110 | MergedBy string `yaml:"merged_by"` | ||
| 111 | MergedAt string `yaml:"merged_at"` | ||
| 103 | 112 | } | |
| 104 | 113 | ||
| 105 | 114 | // Load reads and validates every request on the metadata branch. | |
| @@ -256,6 +265,12 @@ func applyRequest(req *Request, raw, path string, problems *[]string) { | |||
| 256 | 265 | } else { | |
| 257 | 266 | req.Created = t.UTC() | |
| 258 | 267 | } | |
| 268 | // A link to an issue is a promise that a merge closes it, so the id has to be | ||
| 269 | // one this instance could have issued. Existence is checked by the writer, which | ||
| 270 | // has the issues tree; the format is all a reader of one file can know. | ||
| 271 | closes, closeProblems := parseCloses(m.Closes, path) | ||
| 272 | *problems = append(*problems, closeProblems...) | ||
| 273 | req.Closes = closes | ||
| 259 | 274 | // The merged fields are the record of a landing, so they are all-or-nothing and | |
| 260 | 275 | // only exist on a merged request. A file that says "merged" without naming a | |
| 261 | 276 | // commit is refused rather than shown as landed, and a stale merged_head left on | |
| @@ -286,6 +301,32 @@ func applyRequest(req *Request, raw, path string, problems *[]string) { | |||
| 286 | 301 | req.Description = body | |
| 287 | 302 | } | |
| 288 | 303 | ||
| 304 | // parseCloses validates and normalises the issues a request closes: full ids, no | ||
| 305 | // duplicates, sorted, so two writers that link the same issues render the same file. | ||
| 306 | func parseCloses(raw []string, path string) ([]string, []string) { | ||
| 307 | var problems []string | ||
| 308 | seen := make(map[string]bool, len(raw)) | ||
| 309 | out := make([]string, 0, len(raw)) | ||
| 310 | for _, id := range raw { | ||
| 311 | id = strings.TrimSpace(id) | ||
| 312 | if id == "" { | ||
| 313 | continue | ||
| 314 | } | ||
| 315 | if !voleid.Valid(id) { | ||
| 316 | problems = append(problems, fmt.Sprintf( | ||
| 317 | "%s: closes %q is not a full 26-character issue id", path, id)) | ||
| 318 | continue | ||
| 319 | } | ||
| 320 | if seen[id] { | ||
| 321 | continue | ||
| 322 | } | ||
| 323 | seen[id] = true | ||
| 324 | out = append(out, id) | ||
| 325 | } | ||
| 326 | sort.Strings(out) | ||
| 327 | return out, problems | ||
| 328 | } | ||
| 329 | |||
| 289 | 330 | func parseApproval(principal, raw, path string) (Approval, string) { | |
| 290 | 331 | a := Approval{Principal: principal} | |
| 291 | 332 | if !assigneeRE.MatchString(principal) { | |