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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 53m ago
4b8c9e1d
| 1 | // Change requests are a proposed update to a branch, |
| 2 | // plus the approvals that allow it through. |
| 3 | // |
| 4 | // refs/heads/vole |
| 5 | // changes/<ulid>/change.md frontmatter + body |
| 6 | // changes/<ulid>/approvals/<principal>.md frontmatter, no body |
| 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. |
| 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 |
| 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. |
| 17 | // |
| 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. |
| 23 | package metadata |
| 24 | |
| 25 | import ( |
| 26 | "fmt" |
| 27 | "regexp" |
| 28 | "sort" |
| 29 | "strings" |
| 30 | "time" |
| 31 | |
| 32 | "rodent.software/vole/core/git" |
| 33 | voleid "rodent.software/vole/core/id" |
| 34 | "rodent.software/vole/internal/format" |
| 35 | ) |
| 36 | |
| 37 | const ( |
| 38 | changeName = "change.md" |
| 39 | approvalDir = "approvals" |
| 40 | commentDir = "comments" |
| 41 | maxRequestTitleLen = 200 |
| 42 | ) |
| 43 | |
| 44 | var ( |
| 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)$`) |
| 49 | ) |
| 50 | |
| 51 | type Request struct { |
| 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 |
| 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). |
| 62 | HeadRef string |
| 63 | State string // open | merged | closed |
| 64 | Created time.Time |
| 65 | Description string |
| 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 |
| 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 |
| 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 |
| 86 | } |
| 87 | |
| 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 | |
| 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 | |
| 114 | // meta is the frontmatter head of change.md. Field names are part of the |
| 115 | // public format. There is no updated field: the commit carries the time. |
| 116 | type requestMeta struct { |
| 117 | Title string `yaml:"title"` |
| 118 | Author string `yaml:"author"` |
| 119 | Base string `yaml:"base"` |
| 120 | Head string `yaml:"head"` |
| 121 | HeadRef string `yaml:"head_ref"` |
| 122 | State string `yaml:"state"` |
| 123 | Closes []string `yaml:"closes"` |
| 124 | Created string `yaml:"created"` |
| 125 | MergedBase string `yaml:"merged_base"` |
| 126 | MergedHead string `yaml:"merged_head"` |
| 127 | MergedBy string `yaml:"merged_by"` |
| 128 | MergedAt string `yaml:"merged_at"` |
| 129 | } |
| 130 | |
| 131 | // Load reads and validates every request on the metadata branch. |
| 132 | func LoadRequests(g *git.Repo, ref string) ([]*Request, error) { |
| 133 | commit, err := g.RevParse(ref) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 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) { |
| 144 | if commit == "" { |
| 145 | return nil, nil |
| 146 | } |
| 147 | contents, err := readMetadata(g, commit, ChangesDir) |
| 148 | if err != nil { |
| 149 | return nil, err |
| 150 | } |
| 151 | return ParseRequests(contents) |
| 152 | } |
| 153 | |
| 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 | } |
| 165 | contents, err := readMetadata(g, commit, ChangesDir+"/"+id) |
| 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 | |
| 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 | |
| 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 | |
| 228 | // Parse turns requests paths into requests, validating as it goes. |
| 229 | func ParseRequests(contents map[string]string) ([]*Request, error) { |
| 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 { |
| 240 | if !strings.HasPrefix(path, ChangesDir+"/") { |
| 241 | continue |
| 242 | } |
| 243 | m := requestPathRE.FindStringSubmatch(path) |
| 244 | if m == nil { |
| 245 | problems = append(problems, fmt.Sprintf( |
| 246 | "%s: not part of the change format (expected %s/<ulid>/%s, approvals/<account>.md or comments/<ulid>.md)", path, ChangesDir, changeName)) |
| 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 { |
| 256 | case m[2] == changeName: |
| 257 | applyRequest(req, contents[path], path, &problems) |
| 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) |
| 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. |
| 268 | c, prob := ParseComment(m[4], contents[path], path) |
| 269 | if prob != "" { |
| 270 | problems = append(problems, prob) |
| 271 | continue |
| 272 | } |
| 273 | req.Comments = append(req.Comments, c) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | out := make([]*Request, 0, len(byID)) |
| 278 | for _, req := range byID { |
| 279 | if req.Title == "" { |
| 280 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", ChangesDir, req.ID, changeName)) |
| 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 { |
| 285 | if a.Principal == req.Author { |
| 286 | problems = append(problems, fmt.Sprintf( |
| 287 | "%s/%s/%s/%s.md: the author cannot approve their own request", ChangesDir, req.ID, approvalDir, a.Principal)) |
| 288 | } |
| 289 | } |
| 290 | sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal }) |
| 291 | sort.Slice(req.Comments, func(i, j int) bool { return req.Comments[i].ID < req.Comments[j].ID }) |
| 292 | problems = append(problems, commentReplyProblems(req.Comments, ChangesDir+"/"+req.ID)...) |
| 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 | |
| 303 | func applyRequest(req *Request, raw, path string, problems *[]string) { |
| 304 | head, body, err := Split([]byte(raw)) |
| 305 | if err != nil { |
| 306 | *problems = append(*problems, fmt.Sprintf("%s: %v", path, err)) |
| 307 | return |
| 308 | } |
| 309 | var m requestMeta |
| 310 | if err := Decode(head, &m); err != nil { |
| 311 | *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)) |
| 312 | return |
| 313 | } |
| 314 | if err := Text(head, "title", m.Title); err != nil { |
| 315 | *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err)) |
| 316 | } |
| 317 | if err := Text(head, "author", m.Author); err != nil { |
| 318 | *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err)) |
| 319 | } |
| 320 | req.Title = strings.TrimSpace(m.Title) |
| 321 | if len(req.Title) > maxRequestTitleLen { |
| 322 | *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(req.Title), maxRequestTitleLen)) |
| 323 | } |
| 324 | req.Author = strings.TrimSpace(m.Author) |
| 325 | if req.Author == "" { |
| 326 | *problems = append(*problems, fmt.Sprintf("%s: author is required", path)) |
| 327 | } else if !assigneeRE.MatchString(req.Author) { |
| 328 | *problems = append(*problems, fmt.Sprintf( |
| 329 | "%s: author %q is not an account name", path, req.Author)) |
| 330 | } |
| 331 | req.Base = strings.TrimSpace(m.Base) |
| 332 | if !validBranchName(req.Base) { |
| 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 | } |
| 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. |
| 341 | req.HeadRef = strings.TrimSpace(m.HeadRef) |
| 342 | if req.HeadRef != "" { |
| 343 | switch { |
| 344 | case !validBranchName(req.HeadRef): |
| 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 | } |
| 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 | } |
| 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 |
| 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) |
| 381 | req.MergedHead = strings.TrimSpace(m.MergedHead) |
| 382 | req.MergedBy = strings.TrimSpace(m.MergedBy) |
| 383 | if req.State == "merged" { |
| 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 | } |
| 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 | } |
| 407 | } else if req.MergedBase != "" || req.MergedHead != "" || req.MergedBy != "" || strings.TrimSpace(m.MergedAt) != "" { |
| 408 | *problems = append(*problems, fmt.Sprintf( |
| 409 | "%s: merged_base, merged_head, merged_by and merged_at only belong on a merged request (state is %q)", path, req.State)) |
| 410 | } |
| 411 | req.Description = body |
| 412 | *problems = append(*problems, bodyProblems(path, body)...) |
| 413 | } |
| 414 | |
| 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 { |
| 421 | if name == "" || strings.HasPrefix(name, "refs/") || strings.ContainsAny(name, " \t") { |
| 422 | return false |
| 423 | } |
| 424 | return ValidText(name) == nil |
| 425 | } |
| 426 | |
| 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 | |
| 453 | func parseApproval(principal, raw, path string) (Approval, string) { |
| 454 | a := Approval{Principal: principal} |
| 455 | if !assigneeRE.MatchString(principal) { |
| 456 | return a, fmt.Sprintf("%s: the file name must be the approver's account name", path) |
| 457 | } |
| 458 | head, _, err := Split([]byte(raw)) |
| 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"` |
| 466 | } |
| 467 | if err := Decode(head, &m); err != nil { |
| 468 | return a, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err) |
| 469 | } |
| 470 | if !shaRE.MatchString(m.Head) { |
| 471 | return a, fmt.Sprintf("%s: head must be the full commit id that was reviewed", path) |
| 472 | } |
| 473 | a.Head = m.Head |
| 474 | if !validBranchName(m.Base) { |
| 475 | return a, fmt.Sprintf("%s: base must name the branch you approved (e.g. \"main\")", path) |
| 476 | } |
| 477 | a.Base = m.Base |
| 478 | t, err := time.Parse(time.RFC3339, m.Created) |
| 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 | |
| 486 | // CommentPath is where a comment on a request lives. |
| 487 | func RequestCommentPath(requestID, commentID string) string { |
| 488 | return fmt.Sprintf("%s/%s/%s/%s.md", ChangesDir, requestID, commentDir, commentID) |
| 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. |
| 493 | func IsRequestCommentPath(path string) bool { |
| 494 | m := requestPathRE.FindStringSubmatch(path) |
| 495 | return m != nil && m[4] != "" |
| 496 | } |
| 497 | |
| 498 | // ApprovalPath returns the principal an approvals/ path belongs to, or "". |
| 499 | func ApprovalPath(path string) string { |
| 500 | m := requestPathRE.FindStringSubmatch(path) |
| 501 | if m == nil { |
| 502 | return "" |
| 503 | } |
| 504 | return m[3] |
| 505 | } |
| 506 | |
| 507 | // Validate checks the requests subtree at commit. |
| 508 | func ValidateRequests(g *git.Repo, commit string) []string { |
| 509 | if commit == "" || commit == git.ZeroSHA { |
| 510 | return nil |
| 511 | } |
| 512 | blobs, err := g.LsTreeR(commit) |
| 513 | if err != nil { |
| 514 | return []string{fmt.Sprintf("cannot read the changes tree: %v", err)} |
| 515 | } |
| 516 | paths, shas := metadataPaths(blobs, ChangesDir) |
| 517 | if problems := metadataSizeProblems(paths, shas, g); len(problems) > 0 { |
| 518 | return problems |
| 519 | } |
| 520 | contents, err := readBlobs(g, paths, shas) |
| 521 | if err != nil { |
| 522 | return []string{err.Error()} |
| 523 | } |
| 524 | if _, err := ParseRequests(contents); err != nil { |
| 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 |
| 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) |
| 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 | |
| 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 | |
| 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. |
| 603 | func FindApprovedMatch(g *git.Repo, list []*Request, req ApprovalRequirement) (*Merge, error) { |
| 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 |
| 616 | case req.OldTip != "" && req.OldTip != git.ZeroSHA && g != nil: |
| 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 | } |
| 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 | } |
| 656 | } |
| 657 | if !matches { |
| 658 | continue |
| 659 | } |
| 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. |
| 664 | var approvers []string |
| 665 | for _, a := range r.Approvals { |
| 666 | if a.Head == r.Head && a.Base == r.Base && req.counts(a.Principal) { |
| 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 | } |
| 676 |