Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {