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/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.

Blame · metadata/requests.go

Line attribution on main · 675 line(s)

c5902451
Keenan Nemetz
16d ago
1 // Change requests are a proposed update to a branch,
1eeca4e6
Keenan Nemetz
17d ago
2 // plus the approvals that allow it through.
3 //
4 // refs/heads/vole
b492b557
Keenan Nemetz
3d ago
5 // changes/<ulid>/change.md frontmatter + body
6 // changes/<ulid>/approvals/<principal>.md frontmatter, no body
307cbc2a
Keenan Nemetz
17d ago
7 //
8 // Every file on the metadata branch is frontmatter markdown, so one reader and
9 // one writer cover issues, change requests, comments and approvals. A record
10 // with no prose (an approval) ends at the closing fence.
1eeca4e6
Keenan Nemetz
17d ago
11 //
12 // An approval names the exact commit it reviewed. That is the whole point: a
13 // general "I approve this request" is worthless, because the head can move
14 // afterwards. If the head changes, the approvals no longer match and the gate
e050e14a
Keenan Nemetz
13d ago
15 // closes again. A request opened from a branch follows that branch, so a push moves
16 // its head; a request opened from a bare commit stays pinned to that commit.
1eeca4e6
Keenan Nemetz
17d ago
17 //
183d48e5
Keenan Nemetz
13d ago
18 // Landing is either a push the pre-receive hook accepts — it permits a protected
19 // branch when the push carries an approved request — or the server's own merge
20 // (forge/landing), which asks the same gate before it moves the ref. A request names
21 // the issues it closes, and landing closes them in the same commit that records the
22 // merge, so a merged request and a closed issue cannot disagree.
c5902451
Keenan Nemetz
16d ago
23 package metadata
1eeca4e6
Keenan Nemetz
17d ago
24
25 import (
26 "fmt"
27 "regexp"
28 "sort"
29 "strings"
30 "time"
31
d37d301f
Keenan Nemetz
16h ago
32 "rodent.software/vole/core/git"
33 voleid "rodent.software/vole/core/id"
34 "rodent.software/vole/internal/format"
1eeca4e6
Keenan Nemetz
17d ago
35 )
36
37 const (
b492b557
Keenan Nemetz
3d ago
38 changeName = "change.md"
c5902451
Keenan Nemetz
16d ago
39 approvalDir = "approvals"
40 commentDir = "comments"
41 maxRequestTitleLen = 200
1eeca4e6
Keenan Nemetz
17d ago
42 )
43
44 var (
b492b557
Keenan Nemetz
3d ago
45 shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
46 // requestPathRE is the on-disk shape of a change record. The directory comes
47 // from ChangesDir so the parser and the writer cannot drift apart.
48 requestPathRE = regexp.MustCompile(`^` + regexp.QuoteMeta(ChangesDir) + `/([0-9A-HJKMNP-TV-Z]{26})(?:/(change\.md)|/approvals/([a-z0-9][a-z0-9_-]{1,31})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`)
1eeca4e6
Keenan Nemetz
17d ago
49 )
50
51 type Request struct {
8e1005eb
Keenan Nemetz
13d ago
52 ID string
53 Title string
54 Author string // account name: what the approval gate compares against
55 Base string // branch name, e.g. "main"
56 Head string // commit sha under review
e050e14a
Keenan Nemetz
13d ago
57 // HeadRef is the branch the request follows, e.g. "contrib/alice/fix". A push
58 // that moves that branch moves Head to the new tip, which makes approvals of the
59 // old tip stale and asks for checks on the new one. It is empty for requests
60 // opened from a bare commit, for older records, and for a request whose branch
61 // has been deleted (Head keeps the last tip it saw).
8e1005eb
Keenan Nemetz
13d ago
62 HeadRef string
1eeca4e6
Keenan Nemetz
17d ago
63 State string // open | merged | closed
64 Created time.Time
65 Description string
2841d922
Keenan Nemetz
13d ago
66 // MergedBase is what Base pointed at before the landing. It is the "before" side
67 // of a merged request: once the head is in the base, a diff against the base as
68 // it is now is empty, so the record has to name where the branch started from.
69 // It is empty on a record written before this field existed and on a landing that
70 // found the head already in the base, which had no base tip of its own to name.
71 MergedBase string
38b30251
Keenan Nemetz
13d ago
72 // MergedHead is the commit that landed on Base, which is the head itself for a
73 // fast-forward and a merge commit otherwise. It is empty unless State is
74 // "merged": a request that claims a landing has to name one, so the file can
75 // never say "merged" without a commit behind it.
76 MergedHead string
77 MergedBy string // account that ran the merge
78 MergedAt time.Time
183d48e5
Keenan Nemetz
13d ago
79 // Closes are the issues this request is a promise to close. Landing writes
80 // closed onto each of them in the same commit that marks the request merged, so
81 // a request cannot be merged with its issues still open, and an issue cannot be
82 // closed by a merge that did not happen.
83 Closes []string
84 Approvals []Approval
85 Comments []Comment
1eeca4e6
Keenan Nemetz
17d ago
86 }
87
7c6e52e7
Keenan Nemetz
17d ago
88 // Comment is a remark on a request. It is the same file shape as a comment on an
89 // issue — author, created, then the body — so one reader and one writer cover
90 // both, and the ownership rule at the push boundary applies to both.
91
1eeca4e6
Keenan Nemetz
17d ago
92 type Approval struct {
93 Principal string
94 // Base is the branch the approver agreed the head should land on. It is part
95 // of the approval, not of the request, so editing a request's base later does
96 // not silently repoint an approval at a different branch.
97 Base string
98 Head string
99 Created time.Time
100 }
101
102 func (r *Request) Open() bool { return r.State == "open" }
103
104 // ApprovedBy reports whether principal approved this exact head.
105 func (r *Request) ApprovedBy(principal string) bool {
106 for _, a := range r.Approvals {
107 if a.Principal == principal && a.Head == r.Head && a.Base == r.Base {
108 return true
109 }
110 }
111 return false
112 }
113
b492b557
Keenan Nemetz
3d ago
114 // meta is the frontmatter head of change.md. Field names are part of the
307cbc2a
Keenan Nemetz
17d ago
115 // public format. There is no updated field: the commit carries the time.
c5902451
Keenan Nemetz
16d ago
116 type requestMeta struct {
183d48e5
Keenan Nemetz
13d ago
117 Title string `yaml:"title"`
118 Author string `yaml:"author"`
119 Base string `yaml:"base"`
120 Head string `yaml:"head"`
8e1005eb
Keenan Nemetz
13d ago
121 HeadRef string `yaml:"head_ref"`
183d48e5
Keenan Nemetz
13d ago
122 State string `yaml:"state"`
123 Closes []string `yaml:"closes"`
124 Created string `yaml:"created"`
2841d922
Keenan Nemetz
13d ago
125 MergedBase string `yaml:"merged_base"`
183d48e5
Keenan Nemetz
13d ago
126 MergedHead string `yaml:"merged_head"`
127 MergedBy string `yaml:"merged_by"`
128 MergedAt string `yaml:"merged_at"`
1eeca4e6
Keenan Nemetz
17d ago
129 }
130
131 // Load reads and validates every request on the metadata branch.
c5902451
Keenan Nemetz
16d ago
132 func LoadRequests(g *git.Repo, ref string) ([]*Request, error) {
1eeca4e6
Keenan Nemetz
17d ago
133 commit, err := g.RevParse(ref)
134 if err != nil {
135 return nil, err
136 }
73ec800b
Keenan Nemetz
10d ago
137 return LoadRequestsAt(g, commit)
138 }
139
140 // LoadRequestsAt reads the change requests at a commit that has already been
141 // resolved. It exists so a caller that needs both the index and the request
142 // list resolves the metadata ref once, not once per reader.
143 func LoadRequestsAt(g *git.Repo, commit string) ([]*Request, error) {
1eeca4e6
Keenan Nemetz
17d ago
144 if commit == "" {
145 return nil, nil
146 }
b492b557
Keenan Nemetz
3d ago
147 contents, err := readMetadata(g, commit, ChangesDir)
1eeca4e6
Keenan Nemetz
17d ago
148 if err != nil {
149 return nil, err
150 }
c5902451
Keenan Nemetz
16d ago
151 return ParseRequests(contents)
1eeca4e6
Keenan Nemetz
17d ago
152 }
153
c9202f7f
Keenan Nemetz
10d ago
154 // LoadRequestAt reads one change request and its comments at a commit that has
155 // already been resolved, without reading the rest of the tree. The index reads
156 // the entity a changed path belongs to, and a detail page reads the one request
157 // it shows.
158 func LoadRequestAt(g *git.Repo, commit, id string) (*Request, bool, error) {
159 if !voleid.Valid(id) {
160 return nil, false, nil
161 }
162 if commit == "" {
163 return nil, false, nil
164 }
b492b557
Keenan Nemetz
3d ago
165 contents, err := readMetadata(g, commit, ChangesDir+"/"+id)
c9202f7f
Keenan Nemetz
10d ago
166 if err != nil {
167 return nil, false, err
168 }
169 if len(contents) == 0 {
170 return nil, false, nil
171 }
172 list, err := ParseRequests(contents)
173 if err != nil {
174 return nil, false, err
175 }
176 if len(list) == 0 {
177 return nil, false, nil
178 }
179 return list[0], true, nil
180 }
181
0789f22e
Keenan Nemetz
9d ago
182 // NewRequests returns the requests that were added between two commits on the
183 // metadata branch. A missing or zero old commit means the branch is being created,
184 // so every request in it is new.
185 //
186 // Repository policy applies when a request is opened, not to the requests that
187 // were already there. Comparing ids rather than paths is what keeps an edit to an
188 // existing request from being mistaken for a new one.
189 func NewRequests(g *git.Repo, oldCommit, newCommit string) ([]*Request, error) {
190 if newCommit == "" || newCommit == git.ZeroSHA {
191 return nil, nil
192 }
193 current, err := LoadRequestsAt(g, newCommit)
194 if err != nil {
195 return nil, err
196 }
197 if oldCommit == "" || oldCommit == git.ZeroSHA {
198 return current, nil
199 }
200 previous, err := LoadRequestsAt(g, oldCommit)
201 if err != nil {
202 return nil, err
203 }
204 known := make(map[string]bool, len(previous))
205 for _, req := range previous {
206 known[req.ID] = true
207 }
208 out := make([]*Request, 0, len(current))
209 for _, req := range current {
210 if !known[req.ID] {
211 out = append(out, req)
212 }
213 }
214 return out, nil
215 }
216
c9202f7f
Keenan Nemetz
10d ago
217 // RequestIDFromPath returns the request a metadata path belongs to. A changed
218 // file names its entity through this, so a comment or an approval updates the
219 // request it belongs to and nothing else.
220 func RequestIDFromPath(path string) (string, bool) {
221 m := requestPathRE.FindStringSubmatch(path)
222 if m == nil {
223 return "", false
224 }
225 return m[1], true
226 }
227
1eeca4e6
Keenan Nemetz
17d ago
228 // Parse turns requests paths into requests, validating as it goes.
c5902451
Keenan Nemetz
16d ago
229 func ParseRequests(contents map[string]string) ([]*Request, error) {
1eeca4e6
Keenan Nemetz
17d ago
230 var problems []string
231 byID := map[string]*Request{}
232
233 paths := make([]string, 0, len(contents))
234 for p := range contents {
235 paths = append(paths, p)
236 }
237 sort.Strings(paths)
238
239 for _, path := range paths {
b492b557
Keenan Nemetz
3d ago
240 if !strings.HasPrefix(path, ChangesDir+"/") {
1eeca4e6
Keenan Nemetz
17d ago
241 continue
242 }
c5902451
Keenan Nemetz
16d ago
243 m := requestPathRE.FindStringSubmatch(path)
1eeca4e6
Keenan Nemetz
17d ago
244 if m == nil {
245 problems = append(problems, fmt.Sprintf(
b492b557
Keenan Nemetz
3d ago
246 "%s: not part of the change format (expected %s/<ulid>/%s, approvals/<account>.md or comments/<ulid>.md)", path, ChangesDir, changeName))
1eeca4e6
Keenan Nemetz
17d ago
247 continue
248 }
249 id := m[1]
250 req := byID[id]
251 if req == nil {
252 req = &Request{ID: id, State: "open"}
253 byID[id] = req
254 }
255 switch {
b492b557
Keenan Nemetz
3d ago
256 case m[2] == changeName:
307cbc2a
Keenan Nemetz
17d ago
257 applyRequest(req, contents[path], path, &problems)
1eeca4e6
Keenan Nemetz
17d ago
258 case m[3] != "":
259 a, prob := parseApproval(m[3], contents[path], path)
260 if prob != "" {
261 problems = append(problems, prob)
262 continue
263 }
264 req.Approvals = append(req.Approvals, a)
7c6e52e7
Keenan Nemetz
17d ago
265 case m[4] != "":
266 // A comment on a request is read by the same code as a comment on an
267 // issue: one format, one reader, one set of refusals.
c5902451
Keenan Nemetz
16d ago
268 c, prob := ParseComment(m[4], contents[path], path)
7c6e52e7
Keenan Nemetz
17d ago
269 if prob != "" {
270 problems = append(problems, prob)
271 continue
272 }
273 req.Comments = append(req.Comments, c)
1eeca4e6
Keenan Nemetz
17d ago
274 }
275 }
276
277 out := make([]*Request, 0, len(byID))
278 for _, req := range byID {
279 if req.Title == "" {
b492b557
Keenan Nemetz
3d ago
280 problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", ChangesDir, req.ID, changeName))
1eeca4e6
Keenan Nemetz
17d ago
281 }
282 // Self-approval is not an approval. Catching it here rather than at the
283 // gate means the author learns immediately instead of after a rejection.
284 for _, a := range req.Approvals {
62576fa8
Keenan Nemetz
14d ago
285 if a.Principal == req.Author {
1eeca4e6
Keenan Nemetz
17d ago
286 problems = append(problems, fmt.Sprintf(
b492b557
Keenan Nemetz
3d ago
287 "%s/%s/%s/%s.md: the author cannot approve their own request", ChangesDir, req.ID, approvalDir, a.Principal))
1eeca4e6
Keenan Nemetz
17d ago
288 }
289 }
290 sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal })
7c6e52e7
Keenan Nemetz
17d ago
291 sort.Slice(req.Comments, func(i, j int) bool { return req.Comments[i].ID < req.Comments[j].ID })
b492b557
Keenan Nemetz
3d ago
292 problems = append(problems, commentReplyProblems(req.Comments, ChangesDir+"/"+req.ID)...)
1eeca4e6
Keenan Nemetz
17d ago
293 out = append(out, req)
294 }
295 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
296
297 if len(problems) > 0 {
298 return nil, &ValidationError{Problems: problems}
299 }
300 return out, nil
301 }
302
307cbc2a
Keenan Nemetz
17d ago
303 func applyRequest(req *Request, raw, path string, problems *[]string) {
c5902451
Keenan Nemetz
16d ago
304 head, body, err := Split([]byte(raw))
307cbc2a
Keenan Nemetz
17d ago
305 if err != nil {
306 *problems = append(*problems, fmt.Sprintf("%s: %v", path, err))
307 return
308 }
c5902451
Keenan Nemetz
16d ago
309 var m requestMeta
310 if err := Decode(head, &m); err != nil {
307cbc2a
Keenan Nemetz
17d ago
311 *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err))
1eeca4e6
Keenan Nemetz
17d ago
312 return
313 }
c5902451
Keenan Nemetz
16d ago
314 if err := Text(head, "title", m.Title); err != nil {
307cbc2a
Keenan Nemetz
17d ago
315 *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err))
316 }
c5902451
Keenan Nemetz
16d ago
317 if err := Text(head, "author", m.Author); err != nil {
307cbc2a
Keenan Nemetz
17d ago
318 *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err))
319 }
1eeca4e6
Keenan Nemetz
17d ago
320 req.Title = strings.TrimSpace(m.Title)
c5902451
Keenan Nemetz
16d ago
321 if len(req.Title) > maxRequestTitleLen {
322 *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(req.Title), maxRequestTitleLen))
1eeca4e6
Keenan Nemetz
17d ago
323 }
324 req.Author = strings.TrimSpace(m.Author)
325 if req.Author == "" {
326 *problems = append(*problems, fmt.Sprintf("%s: author is required", path))
62576fa8
Keenan Nemetz
14d ago
327 } else if !assigneeRE.MatchString(req.Author) {
31df8bae
Keenan Nemetz
17d ago
328 *problems = append(*problems, fmt.Sprintf(
62576fa8
Keenan Nemetz
14d ago
329 "%s: author %q is not an account name", path, req.Author))
31df8bae
Keenan Nemetz
17d ago
330 }
1eeca4e6
Keenan Nemetz
17d ago
331 req.Base = strings.TrimSpace(m.Base)
e050e14a
Keenan Nemetz
13d ago
332 if !validBranchName(req.Base) {
1eeca4e6
Keenan Nemetz
17d ago
333 *problems = append(*problems, fmt.Sprintf("%s: base must be a branch name, got %q", path, req.Base))
334 }
335 req.Head = strings.TrimSpace(m.Head)
336 if req.Head != "" && !shaRE.MatchString(req.Head) {
337 *problems = append(*problems, fmt.Sprintf("%s: head must be a full 40-character commit id, got %q", path, req.Head))
338 }
e050e14a
Keenan Nemetz
13d ago
339 // HeadRef names the branch this request follows. It needs a commit behind it so
340 // a reader that cannot see the branch still knows what was last reviewed.
8e1005eb
Keenan Nemetz
13d ago
341 req.HeadRef = strings.TrimSpace(m.HeadRef)
342 if req.HeadRef != "" {
343 switch {
e050e14a
Keenan Nemetz
13d ago
344 case !validBranchName(req.HeadRef):
8e1005eb
Keenan Nemetz
13d ago
345 *problems = append(*problems, fmt.Sprintf(
346 "%s: head_ref must be a branch name, got %q", path, req.HeadRef))
347 case req.Head == "":
348 *problems = append(*problems, fmt.Sprintf(
349 "%s: head_ref names a branch, so head must name the commit it pointed at", path))
350 }
351 }
1eeca4e6
Keenan Nemetz
17d ago
352 if m.State == "" {
353 m.State = "open"
354 }
355 switch m.State {
356 case "open", "merged", "closed":
357 req.State = m.State
358 default:
359 *problems = append(*problems, fmt.Sprintf("%s: state must be open, merged or closed, got %q", path, m.State))
360 }
361 if m.Created == "" {
362 *problems = append(*problems, fmt.Sprintf("%s: created is required", path))
363 } else if t, err := time.Parse(time.RFC3339, m.Created); err != nil {
364 *problems = append(*problems, fmt.Sprintf("%s: created must be RFC3339: %v", path, err))
365 } else {
366 req.Created = t.UTC()
367 }
183d48e5
Keenan Nemetz
13d ago
368 // A link to an issue is a promise that a merge closes it, so the id has to be
369 // one this instance could have issued. Existence is checked by the writer, which
370 // has the issues tree; the format is all a reader of one file can know.
371 closes, closeProblems := parseCloses(m.Closes, path)
372 *problems = append(*problems, closeProblems...)
373 req.Closes = closes
2841d922
Keenan Nemetz
13d ago
374 // The merged fields are the record of a landing, so they only exist on a merged
375 // request. A file that says "merged" without naming a commit is refused rather
376 // than shown as landed, and a stale merged_head left on a reopened request is
377 // refused too: the next landing writes a new one. merged_base is the one optional
378 // member: it was added after the first landings and a landing that found the head
379 // already in the base has no base tip of its own to name.
380 req.MergedBase = strings.TrimSpace(m.MergedBase)
38b30251
Keenan Nemetz
13d ago
381 req.MergedHead = strings.TrimSpace(m.MergedHead)
382 req.MergedBy = strings.TrimSpace(m.MergedBy)
383 if req.State == "merged" {
2841d922
Keenan Nemetz
13d ago
384 // merged_base is optional because it was added after the first landings, so a
385 // file without one is an older record, not a broken one. A file that names one
386 // has to name a commit.
387 if req.MergedBase != "" && !shaRE.MatchString(req.MergedBase) {
388 *problems = append(*problems, fmt.Sprintf(
389 "%s: merged_base must be the full commit id the base pointed at before the landing", path))
390 }
38b30251
Keenan Nemetz
13d ago
391 if !shaRE.MatchString(req.MergedHead) {
392 *problems = append(*problems, fmt.Sprintf(
393 "%s: state is merged, so merged_head must be the full commit id that landed", path))
394 }
395 if req.MergedBy == "" {
396 *problems = append(*problems, fmt.Sprintf("%s: state is merged, so merged_by is required", path))
397 } else if !assigneeRE.MatchString(req.MergedBy) {
398 *problems = append(*problems, fmt.Sprintf("%s: merged_by %q is not an account name", path, req.MergedBy))
399 }
400 if m.MergedAt == "" {
401 *problems = append(*problems, fmt.Sprintf("%s: state is merged, so merged_at is required", path))
402 } else if t, err := time.Parse(time.RFC3339, m.MergedAt); err != nil {
403 *problems = append(*problems, fmt.Sprintf("%s: merged_at must be RFC3339: %v", path, err))
404 } else {
405 req.MergedAt = t.UTC()
406 }
2841d922
Keenan Nemetz
13d ago
407 } else if req.MergedBase != "" || req.MergedHead != "" || req.MergedBy != "" || strings.TrimSpace(m.MergedAt) != "" {
38b30251
Keenan Nemetz
13d ago
408 *problems = append(*problems, fmt.Sprintf(
2841d922
Keenan Nemetz
13d ago
409 "%s: merged_base, merged_head, merged_by and merged_at only belong on a merged request (state is %q)", path, req.State))
38b30251
Keenan Nemetz
13d ago
410 }
307cbc2a
Keenan Nemetz
17d ago
411 req.Description = body
4581690d
Keenan Nemetz
13d ago
412 *problems = append(*problems, bodyProblems(path, body)...)
1eeca4e6
Keenan Nemetz
17d ago
413 }
414
e050e14a
Keenan Nemetz
13d ago
415 // validBranchName is the file-format check for base and head_ref: one line, no
416 // spaces, and not a full ref. It is deliberately looser than git's ref rules because
417 // the branch a request names may not exist yet on the reader's side, and because git
418 // is the authority where the branch does exist (Repo.ValidBranch at the writer). A
419 // reader that refused a name git accepts would reject a request written correctly.
420 func validBranchName(name string) bool {
8e1005eb
Keenan Nemetz
13d ago
421 if name == "" || strings.HasPrefix(name, "refs/") || strings.ContainsAny(name, " \t") {
422 return false
423 }
424 return ValidText(name) == nil
425 }
426
183d48e5
Keenan Nemetz
13d ago
427 // parseCloses validates and normalises the issues a request closes: full ids, no
428 // duplicates, sorted, so two writers that link the same issues render the same file.
429 func parseCloses(raw []string, path string) ([]string, []string) {
430 var problems []string
431 seen := make(map[string]bool, len(raw))
432 out := make([]string, 0, len(raw))
433 for _, id := range raw {
434 id = strings.TrimSpace(id)
435 if id == "" {
436 continue
437 }
438 if !voleid.Valid(id) {
439 problems = append(problems, fmt.Sprintf(
440 "%s: closes %q is not a full 26-character issue id", path, id))
441 continue
442 }
443 if seen[id] {
444 continue
445 }
446 seen[id] = true
447 out = append(out, id)
448 }
449 sort.Strings(out)
450 return out, problems
451 }
452
1eeca4e6
Keenan Nemetz
17d ago
453 func parseApproval(principal, raw, path string) (Approval, string) {
454 a := Approval{Principal: principal}
62576fa8
Keenan Nemetz
14d ago
455 if !assigneeRE.MatchString(principal) {
456 return a, fmt.Sprintf("%s: the file name must be the approver's account name", path)
1eeca4e6
Keenan Nemetz
17d ago
457 }
c5902451
Keenan Nemetz
16d ago
458 head, _, err := Split([]byte(raw))
307cbc2a
Keenan Nemetz
17d ago
459 if err != nil {
460 return a, fmt.Sprintf("%s: %v", path, err)
461 }
462 var m struct {
463 Base string `yaml:"base"`
464 Head string `yaml:"head"`
465 Created string `yaml:"created"`
1eeca4e6
Keenan Nemetz
17d ago
466 }
c5902451
Keenan Nemetz
16d ago
467 if err := Decode(head, &m); err != nil {
307cbc2a
Keenan Nemetz
17d ago
468 return a, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)
1eeca4e6
Keenan Nemetz
17d ago
469 }
307cbc2a
Keenan Nemetz
17d ago
470 if !shaRE.MatchString(m.Head) {
1eeca4e6
Keenan Nemetz
17d ago
471 return a, fmt.Sprintf("%s: head must be the full commit id that was reviewed", path)
472 }
307cbc2a
Keenan Nemetz
17d ago
473 a.Head = m.Head
e050e14a
Keenan Nemetz
13d ago
474 if !validBranchName(m.Base) {
1eeca4e6
Keenan Nemetz
17d ago
475 return a, fmt.Sprintf("%s: base must name the branch you approved (e.g. \"main\")", path)
476 }
307cbc2a
Keenan Nemetz
17d ago
477 a.Base = m.Base
478 t, err := time.Parse(time.RFC3339, m.Created)
1eeca4e6
Keenan Nemetz
17d ago
479 if err != nil {
480 return a, fmt.Sprintf("%s: created must be RFC3339: %v", path, err)
481 }
482 a.Created = t.UTC()
483 return a, ""
484 }
485
7c6e52e7
Keenan Nemetz
17d ago
486 // CommentPath is where a comment on a request lives.
c5902451
Keenan Nemetz
16d ago
487 func RequestCommentPath(requestID, commentID string) string {
b492b557
Keenan Nemetz
3d ago
488 return fmt.Sprintf("%s/%s/%s/%s.md", ChangesDir, requestID, commentDir, commentID)
7c6e52e7
Keenan Nemetz
17d ago
489 }
490
491 // IsCommentPath reports whether path is a comment on a request rather than its
492 // body or an approval. The hook uses it to apply comment authorship.
c5902451
Keenan Nemetz
16d ago
493 func IsRequestCommentPath(path string) bool {
494 m := requestPathRE.FindStringSubmatch(path)
7c6e52e7
Keenan Nemetz
17d ago
495 return m != nil && m[4] != ""
496 }
497
1eeca4e6
Keenan Nemetz
17d ago
498 // ApprovalPath returns the principal an approvals/ path belongs to, or "".
499 func ApprovalPath(path string) string {
c5902451
Keenan Nemetz
16d ago
500 m := requestPathRE.FindStringSubmatch(path)
1eeca4e6
Keenan Nemetz
17d ago
501 if m == nil {
502 return ""
503 }
504 return m[3]
505 }
506
507 // Validate checks the requests subtree at commit.
c5902451
Keenan Nemetz
16d ago
508 func ValidateRequests(g *git.Repo, commit string) []string {
509 if commit == "" || commit == git.ZeroSHA {
1eeca4e6
Keenan Nemetz
17d ago
510 return nil
511 }
512 blobs, err := g.LsTreeR(commit)
513 if err != nil {
b492b557
Keenan Nemetz
3d ago
514 return []string{fmt.Sprintf("cannot read the changes tree: %v", err)}
1eeca4e6
Keenan Nemetz
17d ago
515 }
b492b557
Keenan Nemetz
3d ago
516 paths, shas := metadataPaths(blobs, ChangesDir)
4581690d
Keenan Nemetz
13d ago
517 if problems := metadataSizeProblems(paths, shas, g); len(problems) > 0 {
518 return problems
519 }
5cecc618
Keenan Nemetz
10d ago
520 contents, err := readBlobs(g, paths, shas)
521 if err != nil {
522 return []string{err.Error()}
1eeca4e6
Keenan Nemetz
17d ago
523 }
c5902451
Keenan Nemetz
16d ago
524 if _, err := ParseRequests(contents); err != nil {
1eeca4e6
Keenan Nemetz
17d ago
525 if ve, ok := err.(*ValidationError); ok {
526 return ve.Problems
527 }
528 return []string{err.Error()}
529 }
530 return nil
531 }
532
533 // ApprovalRequirement is what the gate needs to decide: how many approvals, and
534 // which commit is being landed.
535 type ApprovalRequirement struct {
536 // Branch is the short branch name being pushed to, e.g. "main".
537 Branch string
538 // NewTip is the commit being written.
539 NewTip string
540 // OldTip is the commit currently at the branch.
541 OldTip string
542 // Required is the number of distinct approvals the ACL demands.
543 Required int
62b59a76
Keenan Nemetz
12d ago
544 // Eligible, when set, filters approvals to the principals whose statement
545 // still counts — an active account that currently holds maintain or admin on
546 // the repository. Nil means every approval counts, which is only right for a
547 // caller with no roster to consult.
548 Eligible func(principal string) bool
549 }
550
551 // counts reports whether an approval from this principal is one the requirement
552 // accepts.
553 func (r ApprovalRequirement) counts(principal string) bool {
554 return r.Eligible == nil || r.Eligible(principal)
1eeca4e6
Keenan Nemetz
17d ago
555 }
556
557 // Merge describes an approved outcome: the request that authorised the push and
558 // the principals whose approvals matched.
559 type Merge struct {
560 Request *Request
561 Approvers []string
562 }
563
e5478085
Keenan Nemetz
11d ago
564 // TreeMismatchError is a pushed merge commit whose parents name a request but whose
565 // tree carries something else. It is separate from "no match" because the refusal
566 // has to say what is wrong: the commit has the shape of a landing without being one.
567 type TreeMismatchError struct {
568 Request *Request
569 NewTip string
570 Expected string
571 Actual string
572 Conflicts []string
573 }
574
575 func (e *TreeMismatchError) Error() string {
576 if len(e.Conflicts) > 0 {
577 return fmt.Sprintf(
578 "the merge commit %s resolves conflicts between %s and change request %s (head %s) that "+
579 "nobody reviewed. Push the branch, resolve the conflicts there, and get the new head "+
580 "approved and tested",
581 format.SHA(e.NewTip), e.Request.Base, e.Request.ID, format.SHA(e.Request.Head))
582 }
583 return fmt.Sprintf(
584 "the merge commit %s does not carry the result of merging %s into this branch: "+
585 "its tree is %s, but the merge of change request %s (head %s) is %s. "+
586 "A merge may not add content nobody approved; push the branch and merge it with Vole instead",
587 format.SHA(e.NewTip), e.Request.Base, format.SHA(e.Actual), e.Request.ID,
588 format.SHA(e.Request.Head), format.SHA(e.Expected))
589 }
590
1eeca4e6
Keenan Nemetz
17d ago
591 // FindApprovedMatch reports whether a push to a protected branch is authorised by
592 // an approved request.
593 //
594 // Two shapes are accepted:
595 //
596 // fast-forward the new tip *is* the approved head
597 // merge the new tip is a merge whose parents are the old tip and the
598 // approved head (either order)
599 //
600 // Anything else is rejected. In particular "the approved head is an ancestor of
601 // the new tip" is NOT accepted: it would let anyone append unapproved commits to
602 // an approved one and land them together.
c5902451
Keenan Nemetz
16d ago
603 func FindApprovedMatch(g *git.Repo, list []*Request, req ApprovalRequirement) (*Merge, error) {
1eeca4e6
Keenan Nemetz
17d ago
604 if req.Required <= 0 {
605 return nil, nil
606 }
607 var nearest error
608 for _, r := range list {
609 if r.State != "open" || r.Base != req.Branch || r.Head == "" {
610 continue
611 }
612 matches := false
613 switch {
614 case r.Head == req.NewTip:
615 matches = true
e5478085
Keenan Nemetz
11d ago
616 case req.OldTip != "" && req.OldTip != git.ZeroSHA && g != nil:
1eeca4e6
Keenan Nemetz
17d ago
617 parents, err := g.CommitParents(req.NewTip)
618 if err != nil {
619 nearest = err
620 continue
621 }
622 if len(parents) == 2 {
623 matches = (parents[0] == req.OldTip && parents[1] == r.Head) ||
624 (parents[1] == req.OldTip && parents[0] == r.Head)
625 }
e5478085
Keenan Nemetz
11d ago
626 if matches {
627 // Parents alone are not enough: "merge, then push" could carry a tree
628 // nobody approved. A landing merge must be exactly the merge of the
629 // reviewed head and the branch tip.
630 expected, conflicts, err := g.MergeTree(req.OldTip, r.Head)
631 if err != nil {
632 nearest = err
633 continue
634 }
635 if len(conflicts) > 0 {
636 // A conflict has no clean merge to compare against. Refuse it and
637 // let the author push a new head rather than accepting a resolution
638 // the server did not review.
639 nearest = &TreeMismatchError{
640 Request: r, NewTip: req.NewTip, Conflicts: conflicts,
641 }
642 continue
643 }
644 actual, err := g.TreeOf(req.NewTip)
645 if err != nil {
646 nearest = err
647 continue
648 }
649 if actual != expected {
650 nearest = &TreeMismatchError{
651 Request: r, NewTip: req.NewTip, Expected: expected, Actual: actual,
652 }
653 continue
654 }
655 }
1eeca4e6
Keenan Nemetz
17d ago
656 }
657 if !matches {
658 continue
659 }
62b59a76
Keenan Nemetz
12d ago
660 // An approval is counted only while the person who wrote it still holds
661 // the standing to give it. Retirement and demotion both have to reach
662 // here: the file stays on the branch, so counting the file alone would let
663 // a revoked maintainer keep landing changes.
1eeca4e6
Keenan Nemetz
17d ago
664 var approvers []string
665 for _, a := range r.Approvals {
62b59a76
Keenan Nemetz
12d ago
666 if a.Head == r.Head && a.Base == r.Base && req.counts(a.Principal) {
1eeca4e6
Keenan Nemetz
17d ago
667 approvers = append(approvers, a.Principal)
668 }
669 }
670 if len(approvers) >= req.Required {
671 return &Merge{Request: r, Approvers: approvers}, nil
672 }
673 }
674 return nil, nearest
675 }