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 · 17m ago
4b8c9e1d
| 1 | package metadata |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "rodent.software/vole/core/git" |
| 10 | voleid "rodent.software/vole/core/id" |
| 11 | "rodent.software/vole/internal/format" |
| 12 | ) |
| 13 | |
| 14 | // New builds a request with a fresh ULID. Nothing is assigned server-side, so |
| 15 | // concurrent authors never contend. |
| 16 | func NewRequest(title, body, author, base, head string, now time.Time) (*Request, error) { |
| 17 | id, err := voleid.New(now) |
| 18 | if err != nil { |
| 19 | return nil, err |
| 20 | } |
| 21 | r := &Request{ |
| 22 | ID: id, |
| 23 | Title: strings.TrimSpace(title), |
| 24 | Author: strings.TrimSpace(author), |
| 25 | Base: base, |
| 26 | Head: head, |
| 27 | State: "open", |
| 28 | Created: now.UTC(), |
| 29 | Description: body, |
| 30 | } |
| 31 | if err := validateRequestForWrite(r); err != nil { |
| 32 | return nil, err |
| 33 | } |
| 34 | return r, nil |
| 35 | } |
| 36 | |
| 37 | func validateRequestForWrite(r *Request) error { |
| 38 | if r.Title == "" { |
| 39 | return fmt.Errorf("requests: title is required") |
| 40 | } |
| 41 | if len(r.Title) > maxRequestTitleLen { |
| 42 | return fmt.Errorf("requests: title is %d bytes, limit %d", len(r.Title), maxRequestTitleLen) |
| 43 | } |
| 44 | if r.Author == "" { |
| 45 | return fmt.Errorf("requests: author is required") |
| 46 | } |
| 47 | if problems := bodyProblems("body", r.Description); len(problems) > 0 { |
| 48 | return fmt.Errorf("requests: %s", problems[0]) |
| 49 | } |
| 50 | if !assigneeRE.MatchString(r.Author) { |
| 51 | return fmt.Errorf("requests: author %q is not an account name", r.Author) |
| 52 | } |
| 53 | if !validBranchName(r.Base) { |
| 54 | return fmt.Errorf("requests: base %q is not a branch name", r.Base) |
| 55 | } |
| 56 | if r.Head != "" && !shaRE.MatchString(r.Head) { |
| 57 | return fmt.Errorf("requests: head %q is not a full commit id", r.Head) |
| 58 | } |
| 59 | if r.HeadRef != "" { |
| 60 | if !validBranchName(r.HeadRef) { |
| 61 | return fmt.Errorf("requests: head_ref %q is not a branch name", r.HeadRef) |
| 62 | } |
| 63 | if r.Head == "" { |
| 64 | return fmt.Errorf("requests: head_ref names a branch, so head must name the commit it pointed at") |
| 65 | } |
| 66 | } |
| 67 | if r.MergedBase != "" && !shaRE.MatchString(r.MergedBase) { |
| 68 | return fmt.Errorf("requests: merged base %q is not a full commit id", r.MergedBase) |
| 69 | } |
| 70 | if _, problems := parseCloses(r.Closes, "request"); len(problems) > 0 { |
| 71 | return fmt.Errorf("%s", strings.Join(problems, "; ")) |
| 72 | } |
| 73 | return nil |
| 74 | } |
| 75 | |
| 76 | // renderRequest writes a request as one file: the head names the branch and the |
| 77 | // commit under review, the body says why. |
| 78 | func renderRequest(r *Request) ([]byte, error) { |
| 79 | d := NewDoc() |
| 80 | d.Text("title", r.Title) |
| 81 | d.Word("state", r.State) |
| 82 | d.Word("author", r.Author) |
| 83 | d.Word("base", r.Base) |
| 84 | d.Word("head", r.Head) |
| 85 | if r.HeadRef != "" { |
| 86 | d.Word("head_ref", r.HeadRef) |
| 87 | } |
| 88 | d.List("closes", r.Closes) |
| 89 | d.Word("created", r.Created.UTC().Format(time.RFC3339)) |
| 90 | if r.State == "merged" { |
| 91 | if r.MergedBase != "" { |
| 92 | d.Word("merged_base", r.MergedBase) |
| 93 | } |
| 94 | d.Word("merged_head", r.MergedHead) |
| 95 | d.Word("merged_by", r.MergedBy) |
| 96 | d.Word("merged_at", r.MergedAt.UTC().Format(time.RFC3339)) |
| 97 | } |
| 98 | return d.Bytes(strings.TrimRight(r.Description, "\n")) |
| 99 | } |
| 100 | |
| 101 | // renderApproval writes an approval: three constrained fields and no body. |
| 102 | func renderApproval(a Approval) ([]byte, error) { |
| 103 | d := NewDoc() |
| 104 | d.Word("base", a.Base) |
| 105 | d.Word("head", a.Head) |
| 106 | d.Word("created", a.Created.UTC().Format(time.RFC3339)) |
| 107 | return d.Bytes("") |
| 108 | } |
| 109 | |
| 110 | // Create writes a new request onto the metadata branch. |
| 111 | func CreateRequest(g *git.Repo, r *Request, actor string) error { |
| 112 | if err := validateRequestForWrite(r); err != nil { |
| 113 | return err |
| 114 | } |
| 115 | raw, err := renderRequest(r) |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | base := fmt.Sprintf("%s/%s/", ChangesDir, r.ID) |
| 120 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 121 | if _, exists := files[base+changeName]; exists { |
| 122 | return fmt.Errorf("requests: %s already exists", r.ID) |
| 123 | } |
| 124 | // A link is a promise that a merge closes the issue, so it has to name one. |
| 125 | // Checking it against the tree this commit replaces means a typo is refused |
| 126 | // while the author is still looking at it, instead of at the landing of a |
| 127 | // request they waited on. |
| 128 | if err := issuesExist(files, r.Closes); err != nil { |
| 129 | return err |
| 130 | } |
| 131 | files[base+changeName] = raw |
| 132 | return nil |
| 133 | }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor) |
| 134 | return err |
| 135 | } |
| 136 | |
| 137 | // Get returns one request by id. It reads that request's files, not the whole |
| 138 | // branch: a page about one change should not pay for every other change. |
| 139 | func GetRequest(g *git.Repo, id string) (*Request, error) { |
| 140 | commit, err := g.RevParse(Ref) |
| 141 | if err != nil { |
| 142 | return nil, err |
| 143 | } |
| 144 | req, found, err := LoadRequestAt(g, commit, id) |
| 145 | if err != nil { |
| 146 | return nil, err |
| 147 | } |
| 148 | if !found { |
| 149 | return nil, fmt.Errorf("requests: no request %s", id) |
| 150 | } |
| 151 | return req, nil |
| 152 | } |
| 153 | |
| 154 | // SetRequestDetails replaces the title and description of a request. The prose is |
| 155 | // not what an approval names — that is the base and head — so correcting it does |
| 156 | // not start the review over, and it stays possible after the request is closed or |
| 157 | // merged, when a record with a typo is still worth fixing. The writer validates the |
| 158 | // whole record, so an edit cannot leave a request the reader would refuse. |
| 159 | func SetRequestDetails(g *git.Repo, id, title, body, actor string) (*Request, error) { |
| 160 | title = strings.TrimSpace(title) |
| 161 | var out *Request |
| 162 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 163 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 164 | r, err := loadFrom(files, id) |
| 165 | if err != nil { |
| 166 | return err |
| 167 | } |
| 168 | if r.Title == title && r.Description == body { |
| 169 | out = r |
| 170 | return ErrNoChange |
| 171 | } |
| 172 | r.Title = title |
| 173 | r.Description = body |
| 174 | if err := validateRequestForWrite(r); err != nil { |
| 175 | return err |
| 176 | } |
| 177 | raw, err := renderRequest(r) |
| 178 | if err != nil { |
| 179 | return err |
| 180 | } |
| 181 | files[base+changeName] = raw |
| 182 | out = r |
| 183 | return nil |
| 184 | }, fmt.Sprintf("request: update %s", id), actor) |
| 185 | if errors.Is(err, ErrNoChange) { |
| 186 | return out, nil |
| 187 | } |
| 188 | return out, err |
| 189 | } |
| 190 | |
| 191 | // SetRequestHead repoints an open request at a commit and, optionally, at the |
| 192 | // branch it should follow. A non-empty headRef makes the request follow that |
| 193 | // branch, so the next push moves the head again; an empty one pins it to head. |
| 194 | // Approvals stay in the tree but stop matching when the head moves, because they |
| 195 | // name the commit they reviewed. |
| 196 | // |
| 197 | // Only an open request may move: a merged request's head is the record of what |
| 198 | // landed, and a closed one is not going to land at all. Refusing here rather than |
| 199 | // in each caller is what keeps the CLI and the browser on the same rule. |
| 200 | func SetRequestHead(g *git.Repo, id, head, headRef, actor string) (*Request, error) { |
| 201 | var out *Request |
| 202 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 203 | message := fmt.Sprintf("request: %s head -> %s", id, format.SHA(head)) |
| 204 | if headRef != "" { |
| 205 | message = fmt.Sprintf("request: %s follows %s at %s", id, headRef, format.SHA(head)) |
| 206 | } |
| 207 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 208 | r, err := loadFrom(files, id) |
| 209 | if err != nil { |
| 210 | return err |
| 211 | } |
| 212 | if r.State != "open" { |
| 213 | return fmt.Errorf("requests: %s is %s, so its head cannot move", id, r.State) |
| 214 | } |
| 215 | r.Head = head |
| 216 | r.HeadRef = headRef |
| 217 | if err := validateRequestForWrite(r); err != nil { |
| 218 | return err |
| 219 | } |
| 220 | raw, err := renderRequest(r) |
| 221 | if err != nil { |
| 222 | return err |
| 223 | } |
| 224 | files[base+changeName] = raw |
| 225 | out = r |
| 226 | return nil |
| 227 | }, message, actor) |
| 228 | return out, err |
| 229 | } |
| 230 | |
| 231 | // SetRequestBase retargets an open request at another base branch. It is how a |
| 232 | // stacked request is repointed when the branch below it lands: the diff is now |
| 233 | // against the new base, and every approval named the old base, so the review starts |
| 234 | // over. |
| 235 | // |
| 236 | // Only an open request may move: a merged request's base is the record of what it |
| 237 | // landed on, and a closed one is not going to land at all. |
| 238 | func SetRequestBase(g *git.Repo, id, newBase, actor string) (*Request, error) { |
| 239 | var out *Request |
| 240 | dir := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 241 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 242 | r, err := loadFrom(files, id) |
| 243 | if err != nil { |
| 244 | return err |
| 245 | } |
| 246 | if r.State != "open" { |
| 247 | return fmt.Errorf("requests: %s is %s, so its base cannot move", id, r.State) |
| 248 | } |
| 249 | if r.Base == newBase { |
| 250 | out = r |
| 251 | return ErrNoChange |
| 252 | } |
| 253 | r.Base = newBase |
| 254 | if err := validateRequestForWrite(r); err != nil { |
| 255 | return err |
| 256 | } |
| 257 | raw, err := renderRequest(r) |
| 258 | if err != nil { |
| 259 | return err |
| 260 | } |
| 261 | files[dir+changeName] = raw |
| 262 | out = r |
| 263 | return nil |
| 264 | }, fmt.Sprintf("request: %s base -> %s", id, newBase), actor) |
| 265 | if errors.Is(err, ErrNoChange) { |
| 266 | return out, nil |
| 267 | } |
| 268 | return out, err |
| 269 | } |
| 270 | |
| 271 | // SetRequestTarget moves an open request's base and the branch it follows in one |
| 272 | // metadata commit. It is the writer behind the request page's single target control: |
| 273 | // changing both together avoids a request that briefly points at a base it was never |
| 274 | // reviewed against. Approvals name the old base and commit, so either change makes |
| 275 | // them stale. |
| 276 | func SetRequestTarget(g *git.Repo, id, newBase, newHead, newHeadRef, actor string) (*Request, error) { |
| 277 | var out *Request |
| 278 | dir := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 279 | target := newHeadRef |
| 280 | if target == "" { |
| 281 | target = format.SHA(newHead) |
| 282 | } |
| 283 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 284 | r, err := loadFrom(files, id) |
| 285 | if err != nil { |
| 286 | return err |
| 287 | } |
| 288 | if r.State != "open" { |
| 289 | return fmt.Errorf("requests: %s is %s, so its target cannot move", id, r.State) |
| 290 | } |
| 291 | if r.Base == newBase && r.Head == newHead && r.HeadRef == newHeadRef { |
| 292 | out = r |
| 293 | return ErrNoChange |
| 294 | } |
| 295 | r.Base = newBase |
| 296 | r.Head = newHead |
| 297 | r.HeadRef = newHeadRef |
| 298 | if err := validateRequestForWrite(r); err != nil { |
| 299 | return err |
| 300 | } |
| 301 | raw, err := renderRequest(r) |
| 302 | if err != nil { |
| 303 | return err |
| 304 | } |
| 305 | files[dir+changeName] = raw |
| 306 | out = r |
| 307 | return nil |
| 308 | }, fmt.Sprintf("request: %s target %s <- %s", id, newBase, target), actor) |
| 309 | if errors.Is(err, ErrNoChange) { |
| 310 | return out, nil |
| 311 | } |
| 312 | return out, err |
| 313 | } |
| 314 | |
| 315 | // SyncBranchHead moves every open request that follows branch to head. It is the |
| 316 | // server-side half of following a branch: post-receive calls it after the ref has |
| 317 | // moved, so the request names the tip a reviewer will see. It changes only head; |
| 318 | // approvals of the old tip stay where their owners wrote them and stop matching, |
| 319 | // and checks are keyed by commit, so the new tip starts without any. |
| 320 | // |
| 321 | // This is deliberately separate from SetRequestHead: it is the server reacting to |
| 322 | // a push, not an account moving a request, so it does not take an actor's |
| 323 | // permission to edit the request. The only field it can change is head, and only |
| 324 | // for a request that already named this branch. |
| 325 | func SyncBranchHead(g *git.Repo, branch, head, actor string) (int, error) { |
| 326 | if branch == "" || !shaRE.MatchString(head) { |
| 327 | return 0, nil |
| 328 | } |
| 329 | updated := 0 |
| 330 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 331 | // CommitFiles re-runs this on a lost race, so the count is per attempt. |
| 332 | n, err := followBranch(files, branch, head) |
| 333 | if err != nil { |
| 334 | return err |
| 335 | } |
| 336 | updated = n |
| 337 | if n == 0 { |
| 338 | return ErrNoChange |
| 339 | } |
| 340 | return nil |
| 341 | }, fmt.Sprintf("request: follow %s at %s", branch, format.SHA(head)), actor) |
| 342 | if errors.Is(err, ErrNoChange) { |
| 343 | return 0, nil |
| 344 | } |
| 345 | if err != nil { |
| 346 | return 0, err |
| 347 | } |
| 348 | return updated, nil |
| 349 | } |
| 350 | |
| 351 | // followBranch moves every open request that follows branch onto head, writing |
| 352 | // the changed request files into an open commit. It is SyncBranchHead's body, |
| 353 | // shared with the merge mark: a landing moves its base branch without running |
| 354 | // post-receive, so it has to move the requests following that branch in the same |
| 355 | // commit that marks it merged. |
| 356 | func followBranch(files map[string][]byte, branch, head string) (int, error) { |
| 357 | if branch == "" || !shaRE.MatchString(head) { |
| 358 | return 0, nil |
| 359 | } |
| 360 | contents := make(map[string]string) |
| 361 | for path, raw := range files { |
| 362 | if strings.HasPrefix(path, ChangesDir+"/") { |
| 363 | contents[path] = string(raw) |
| 364 | } |
| 365 | } |
| 366 | list, err := ParseRequests(contents) |
| 367 | if err != nil { |
| 368 | return 0, fmt.Errorf("requests: cannot follow %s: %w", branch, err) |
| 369 | } |
| 370 | updated := 0 |
| 371 | for _, r := range list { |
| 372 | if r.State != "open" || r.HeadRef != branch || r.Head == head { |
| 373 | continue |
| 374 | } |
| 375 | r.Head = head |
| 376 | raw, err := renderRequest(r) |
| 377 | if err != nil { |
| 378 | return 0, err |
| 379 | } |
| 380 | files[fmt.Sprintf("%s/%s/%s", ChangesDir, r.ID, changeName)] = raw |
| 381 | updated++ |
| 382 | } |
| 383 | return updated, nil |
| 384 | } |
| 385 | |
| 386 | // Approve records principal's approval of the request's current head and base. |
| 387 | // |
| 388 | // expectedBase and expectedHead are the target the approver was looking at. If a |
| 389 | // push moved the followed branch, or the request was retargeted at another base, |
| 390 | // the approval is refused rather than silently transferred to a target nobody |
| 391 | // reviewed, and the caller reads the request again. |
| 392 | func ApproveRequest(g *git.Repo, id, principal, expectedBase, expectedHead string, now time.Time, actor string) (*Request, error) { |
| 393 | if !assigneeRE.MatchString(principal) { |
| 394 | return nil, fmt.Errorf("requests: %q is not an account name", principal) |
| 395 | } |
| 396 | var out *Request |
| 397 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 398 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 399 | r, err := loadFrom(files, id) |
| 400 | if err != nil { |
| 401 | return err |
| 402 | } |
| 403 | if r.State != "open" { |
| 404 | return fmt.Errorf("requests: %s is %s, so it cannot be approved", id, r.State) |
| 405 | } |
| 406 | if r.Author == principal { |
| 407 | return fmt.Errorf("requests: the author cannot approve their own request") |
| 408 | } |
| 409 | if r.Head == "" { |
| 410 | return fmt.Errorf("requests: %s has no head commit to approve", id) |
| 411 | } |
| 412 | if expectedBase != "" && r.Base != expectedBase { |
| 413 | return fmt.Errorf( |
| 414 | "requests: %s moved base from %s to %s before the approval was written; review the new base and approve again", |
| 415 | id, expectedBase, r.Base) |
| 416 | } |
| 417 | if expectedHead != "" && r.Head != expectedHead { |
| 418 | return fmt.Errorf( |
| 419 | "requests: %s moved from %s to %s before the approval was written; review the new head and approve again", |
| 420 | id, format.SHA(expectedHead), format.SHA(r.Head)) |
| 421 | } |
| 422 | raw, err := renderApproval(Approval{ |
| 423 | Principal: principal, |
| 424 | Base: r.Base, |
| 425 | Head: r.Head, |
| 426 | Created: now.UTC(), |
| 427 | }) |
| 428 | if err != nil { |
| 429 | return err |
| 430 | } |
| 431 | files[fmt.Sprintf("%s%s/%s.md", base, approvalDir, principal)] = raw |
| 432 | out = r |
| 433 | return nil |
| 434 | }, fmt.Sprintf("approve: %s by %s", id, principal), actor) |
| 435 | return out, err |
| 436 | } |
| 437 | |
| 438 | // SetState moves a request between open and closed. |
| 439 | func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) { |
| 440 | // "merged" is not a state a caller may set: it is the outcome of a landing, and |
| 441 | // it has to travel with the commit that landed. Use MarkMerged, which writes the |
| 442 | // state and the commit together, so a request cannot claim a merge no commit |
| 443 | // backs. |
| 444 | switch state { |
| 445 | case "open", "closed": |
| 446 | default: |
| 447 | return nil, fmt.Errorf("requests: state must be open or closed") |
| 448 | } |
| 449 | var out *Request |
| 450 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 451 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 452 | r, err := loadFrom(files, id) |
| 453 | if err != nil { |
| 454 | return err |
| 455 | } |
| 456 | r.State = state |
| 457 | raw, err := renderRequest(r) |
| 458 | if err != nil { |
| 459 | return err |
| 460 | } |
| 461 | files[base+changeName] = raw |
| 462 | out = r |
| 463 | return nil |
| 464 | }, fmt.Sprintf("request: %s %s", id, state), actor) |
| 465 | return out, err |
| 466 | } |
| 467 | |
| 468 | // MarkMerged records a landing: the state becomes merged and the request names the |
| 469 | // commit that landed, the base tip it landed on, who landed it, and when. The mark is |
| 470 | // written in the same commit as the state, so the file cannot say "merged" and mean |
| 471 | // nothing. |
| 472 | // |
| 473 | // landedBase is what the base branch pointed at before the landing. It is what the |
| 474 | // Files tab diffs a merged request against, because the head is in the base |
| 475 | // afterwards and a diff against the base as it is now says nothing. A caller that |
| 476 | // does not know it — a retry that finds the head already landed has no base tip of |
| 477 | // its own to name — passes "", which leaves whatever the record already says. |
| 478 | // |
| 479 | // reviewedHead is the commit the approvals named. It is written to head as the |
| 480 | // record is marked merged, because a followed branch may have moved after the |
| 481 | // landing was prepared and the merged request must describe what actually landed. |
| 482 | // |
| 483 | // A request that is already merged with the same commit is left alone and returned |
| 484 | // unchanged, which makes a retry after a half-finished landing harmless. |
| 485 | func MarkMerged(g *git.Repo, id, landedBase, landedHead, reviewedHead, actor string, at time.Time) (*Request, []string, error) { |
| 486 | if !shaRE.MatchString(landedHead) { |
| 487 | return nil, nil, fmt.Errorf("requests: merged head must be a full commit id, got %q", landedHead) |
| 488 | } |
| 489 | if landedBase != "" && !shaRE.MatchString(landedBase) { |
| 490 | return nil, nil, fmt.Errorf("requests: merged base must be a full commit id, got %q", landedBase) |
| 491 | } |
| 492 | if reviewedHead != "" && !shaRE.MatchString(reviewedHead) { |
| 493 | return nil, nil, fmt.Errorf("requests: reviewed head must be a full commit id, got %q", reviewedHead) |
| 494 | } |
| 495 | var ( |
| 496 | out *Request |
| 497 | closed []string |
| 498 | ) |
| 499 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 500 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 501 | r, err := loadFrom(files, id) |
| 502 | if err != nil { |
| 503 | return err |
| 504 | } |
| 505 | if landedBase == "" { |
| 506 | landedBase = r.MergedBase // a retry that cannot name the base keeps the one it has |
| 507 | } |
| 508 | if r.State == "merged" && r.MergedHead == landedHead && r.MergedBase == landedBase { |
| 509 | out = r |
| 510 | return ErrNoChange |
| 511 | } |
| 512 | r.State = "merged" |
| 513 | // Freeze the commit the review named. The branch may have moved after the |
| 514 | // landing was prepared; the merged record has to describe what landed, not |
| 515 | // where the branch went next. |
| 516 | if reviewedHead != "" { |
| 517 | r.Head = reviewedHead |
| 518 | } |
| 519 | r.MergedBase = landedBase |
| 520 | r.MergedHead = landedHead |
| 521 | r.MergedBy = actor |
| 522 | r.MergedAt = at.UTC() |
| 523 | raw, err := renderRequest(r) |
| 524 | if err != nil { |
| 525 | return err |
| 526 | } |
| 527 | files[base+changeName] = raw |
| 528 | // The issues this request promised to close are closed in the same commit as |
| 529 | // the mark, so the merge and the closure cannot be separated by a crash. An |
| 530 | // issue that is already closed, or that is no longer there, is left alone: the |
| 531 | // promise is about what the merge did, not about overriding a later decision. |
| 532 | ids, err := closeIssues(files, r.Closes) |
| 533 | if err != nil { |
| 534 | return err |
| 535 | } |
| 536 | closed = ids |
| 537 | // The landing moved r.Base, so the requests that follow that branch move |
| 538 | // with it. This is the rule post-receive applies to a push, applied to the |
| 539 | // merge that was not a push: without it a stacked request keeps reviewing |
| 540 | // the pre-merge tip. Same commit as the mark, so the two cannot disagree. |
| 541 | if _, err := followBranch(files, r.Base, landedHead); err != nil { |
| 542 | return err |
| 543 | } |
| 544 | out = r |
| 545 | return nil |
| 546 | }, fmt.Sprintf("request: %s merged %s", id, landedHead), actor) |
| 547 | if errors.Is(err, ErrNoChange) { |
| 548 | return out, nil, nil |
| 549 | } |
| 550 | if err != nil { |
| 551 | return nil, nil, err |
| 552 | } |
| 553 | return out, closed, nil |
| 554 | } |
| 555 | |
| 556 | // closeIssues rewrites the named issues as closed inside an open metadata commit, |
| 557 | // and returns the ones it actually changed. Unknown and already-closed issues are |
| 558 | // skipped: a request may outlive the issue it was filed against, and a merge must not |
| 559 | // reopen-and-close something somebody closed on purpose. |
| 560 | func closeIssues(files map[string][]byte, ids []string) ([]string, error) { |
| 561 | if len(ids) == 0 { |
| 562 | return nil, nil |
| 563 | } |
| 564 | contents := make(map[string]string, len(files)) |
| 565 | for path, raw := range files { |
| 566 | if strings.HasPrefix(path, IssuesDir+"/") { |
| 567 | contents[path] = string(raw) |
| 568 | } |
| 569 | } |
| 570 | issues, err := ParseIssues(contents) |
| 571 | if err != nil { |
| 572 | return nil, fmt.Errorf("requests: cannot read the issues this request closes: %w", err) |
| 573 | } |
| 574 | byID := make(map[string]*Issue, len(issues)) |
| 575 | for _, iss := range issues { |
| 576 | byID[iss.ID] = iss |
| 577 | } |
| 578 | var closed []string |
| 579 | for _, id := range ids { |
| 580 | iss, ok := byID[id] |
| 581 | if !ok || iss.State == "closed" { |
| 582 | continue |
| 583 | } |
| 584 | iss.State = "closed" |
| 585 | raw, err := renderIssue(iss) |
| 586 | if err != nil { |
| 587 | return nil, err |
| 588 | } |
| 589 | files[fmt.Sprintf("%s/%s/%s", IssuesDir, id, issueName)] = raw |
| 590 | closed = append(closed, id) |
| 591 | } |
| 592 | return closed, nil |
| 593 | } |
| 594 | |
| 595 | // NewComment builds a comment on a request with a fresh id. |
| 596 | func NewRequestComment(body, author string, now time.Time) (*Comment, error) { |
| 597 | commentID, err := voleid.New(now) |
| 598 | if err != nil { |
| 599 | return nil, err |
| 600 | } |
| 601 | c := &Comment{ID: commentID, Author: strings.TrimSpace(author), Created: now.UTC(), Body: body} |
| 602 | if err := validateComment(c); err != nil { |
| 603 | return nil, err |
| 604 | } |
| 605 | return c, nil |
| 606 | } |
| 607 | |
| 608 | func validateComment(c *Comment) error { |
| 609 | if strings.TrimSpace(c.Body) == "" { |
| 610 | return fmt.Errorf("requests: a comment needs a body") |
| 611 | } |
| 612 | if problems := bodyProblems("comment", c.Body); len(problems) > 0 { |
| 613 | return fmt.Errorf("requests: %s", problems[0]) |
| 614 | } |
| 615 | if strings.TrimSpace(c.Author) == "" { |
| 616 | return fmt.Errorf("requests: a comment needs an author") |
| 617 | } |
| 618 | return nil |
| 619 | } |
| 620 | |
| 621 | // AddComment appends a comment to a request. It is the same file shape a comment |
| 622 | // on an issue has, written by the same code, so review discussion needs no format |
| 623 | // of its own. |
| 624 | func AddRequestComment(g *git.Repo, requestID string, c *Comment, actor string) error { |
| 625 | if strings.TrimSpace(c.Author) == "" { |
| 626 | c.Author = actor |
| 627 | } |
| 628 | if c.Created.IsZero() { |
| 629 | c.Created = time.Now().UTC() |
| 630 | } |
| 631 | if err := validateComment(c); err != nil { |
| 632 | return err |
| 633 | } |
| 634 | raw, err := RenderComment(c) |
| 635 | if err != nil { |
| 636 | return err |
| 637 | } |
| 638 | path := RequestCommentPath(requestID, c.ID) |
| 639 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 640 | r, err := loadFrom(files, requestID) |
| 641 | if err != nil { |
| 642 | return err |
| 643 | } |
| 644 | if prob := checkCommentReply(r.Comments, c); prob != "" { |
| 645 | return fmt.Errorf("requests: %s", prob) |
| 646 | } |
| 647 | if _, exists := files[path]; exists { |
| 648 | return fmt.Errorf("requests: comment %s already exists", c.ID) |
| 649 | } |
| 650 | files[path] = raw |
| 651 | return nil |
| 652 | }, fmt.Sprintf("request: comment on %s", requestID), c.Author) |
| 653 | return err |
| 654 | } |
| 655 | |
| 656 | // EditComment rewrites a comment's body in place. The path stays, so the comment |
| 657 | // keeps its id and one `git log -p -- <path>` answers what it used to say; there |
| 658 | // is no `edited` field, for the reason nothing else in this format has one. |
| 659 | func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) (*Comment, error) { |
| 660 | body = strings.TrimRight(body, "\n") |
| 661 | var out *Comment |
| 662 | path := RequestCommentPath(requestID, commentID) |
| 663 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { |
| 664 | r, err := loadFrom(files, requestID) |
| 665 | if err != nil { |
| 666 | return "", err |
| 667 | } |
| 668 | c, ok := commentOf(r.Comments, commentID) |
| 669 | if !ok { |
| 670 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) |
| 671 | } |
| 672 | if c.Deleted { |
| 673 | return "", fmt.Errorf("requests: cannot edit a deleted comment") |
| 674 | } |
| 675 | c.Body = body |
| 676 | if err := validateComment(c); err != nil { |
| 677 | return "", err |
| 678 | } |
| 679 | raw, err := RenderComment(c) |
| 680 | if err != nil { |
| 681 | return "", err |
| 682 | } |
| 683 | files[path] = raw |
| 684 | out = c |
| 685 | return actorOr(actor, c.Author), nil |
| 686 | }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID)) |
| 687 | if err != nil { |
| 688 | return nil, err |
| 689 | } |
| 690 | return out, nil |
| 691 | } |
| 692 | |
| 693 | // DeleteComment replaces a comment with a tombstone. The text stays in history: |
| 694 | // deleting is not redaction, which is an admin force-push. Replies keep the |
| 695 | // parent they answer. |
| 696 | func DeleteRequestComment(g *git.Repo, requestID, commentID, actor string) (*Comment, error) { |
| 697 | var out *Comment |
| 698 | path := RequestCommentPath(requestID, commentID) |
| 699 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { |
| 700 | r, err := loadFrom(files, requestID) |
| 701 | if err != nil { |
| 702 | return "", err |
| 703 | } |
| 704 | c, ok := commentOf(r.Comments, commentID) |
| 705 | if !ok { |
| 706 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) |
| 707 | } |
| 708 | if c.Deleted { |
| 709 | return "", fmt.Errorf("requests: comment %s is already deleted", commentID) |
| 710 | } |
| 711 | c.Deleted = true |
| 712 | c.Body = "" |
| 713 | c.Anchor = nil |
| 714 | raw, err := RenderComment(c) |
| 715 | if err != nil { |
| 716 | return "", err |
| 717 | } |
| 718 | files[path] = raw |
| 719 | out = c |
| 720 | return actorOr(actor, c.Author), nil |
| 721 | }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID)) |
| 722 | if err != nil { |
| 723 | return nil, err |
| 724 | } |
| 725 | return out, nil |
| 726 | } |
| 727 | |
| 728 | // loadFrom reads a request out of the tree map a commit is being built from, so |
| 729 | // mutations always see the tip they are about to modify. |
| 730 | func loadFrom(files map[string][]byte, id string) (*Request, error) { |
| 731 | contents := make(map[string]string, len(files)) |
| 732 | for k, v := range files { |
| 733 | contents[k] = string(v) |
| 734 | } |
| 735 | list, err := ParseRequests(contents) |
| 736 | if err != nil { |
| 737 | if ve, ok := err.(*ValidationError); ok { |
| 738 | return nil, fmt.Errorf("requests: the metadata tree is invalid: %s", ve.Problems[0]) |
| 739 | } |
| 740 | return nil, err |
| 741 | } |
| 742 | for _, r := range list { |
| 743 | if r.ID == id { |
| 744 | return r, nil |
| 745 | } |
| 746 | } |
| 747 | return nil, fmt.Errorf("requests: no request %s", id) |
| 748 | } |
| 749 | |
| 750 | // SetRequestCloses replaces the issues a request promises to close. An open request is |
| 751 | // the only thing that can land, so it is the only thing whose links are worth |
| 752 | // changing: a merged request's list is a record of what the merge did, and a closed |
| 753 | // one is not going to run. |
| 754 | // |
| 755 | // The ids are checked against the issues tree here as well as at creation, because a |
| 756 | // request may be linked long after it was opened, and a typo at that point would |
| 757 | // otherwise surface as an issue that quietly never closes. |
| 758 | func SetRequestCloses(g *git.Repo, id string, closes []string, actor string) (*Request, error) { |
| 759 | closes, problems := parseCloses(closes, "request") |
| 760 | if len(problems) > 0 { |
| 761 | return nil, fmt.Errorf("%s", strings.Join(problems, "; ")) |
| 762 | } |
| 763 | var out *Request |
| 764 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
| 765 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 766 | r, err := loadFrom(files, id) |
| 767 | if err != nil { |
| 768 | return err |
| 769 | } |
| 770 | if r.State != "open" { |
| 771 | return fmt.Errorf("requests: %s is %s, so its links cannot change", id, r.State) |
| 772 | } |
| 773 | if equalStrings(r.Closes, closes) { |
| 774 | out = r |
| 775 | return ErrNoChange |
| 776 | } |
| 777 | // The ids are checked against the issues tree this commit replaces, because a |
| 778 | // request may be linked long after it was opened, and a typo at that point |
| 779 | // would otherwise surface as an issue that quietly never closes. |
| 780 | if err := issuesExist(files, closes); err != nil { |
| 781 | return err |
| 782 | } |
| 783 | r.Closes = closes |
| 784 | raw, err := renderRequest(r) |
| 785 | if err != nil { |
| 786 | return err |
| 787 | } |
| 788 | files[base+changeName] = raw |
| 789 | out = r |
| 790 | return nil |
| 791 | }, fmt.Sprintf("request: %s closes %d issue(s)", id, len(closes)), actor) |
| 792 | if errors.Is(err, ErrNoChange) { |
| 793 | return out, nil |
| 794 | } |
| 795 | return out, err |
| 796 | } |
| 797 | |
| 798 | // issuesExist reports whether every issue a request promises to close is on the |
| 799 | // branch the request is being written to. It reads the map the commit is built from, |
| 800 | // so the promise is checked against the issues the writer is about to keep. |
| 801 | func issuesExist(files map[string][]byte, closes []string) error { |
| 802 | if len(closes) == 0 { |
| 803 | return nil |
| 804 | } |
| 805 | known := make(map[string]bool, len(files)) |
| 806 | for path := range files { |
| 807 | if id, ok := IssueIDFromPath(path); ok && !IsIssueCommentPath(path) { |
| 808 | known[id] = true |
| 809 | } |
| 810 | } |
| 811 | for _, id := range closes { |
| 812 | if !known[id] { |
| 813 | return fmt.Errorf("requests: closes %s, and there is no such issue", id) |
| 814 | } |
| 815 | } |
| 816 | return nil |
| 817 | } |
| 818 | |
| 819 | func equalStrings(a, b []string) bool { |
| 820 | if len(a) != len(b) { |
| 821 | return false |
| 822 | } |
| 823 | for i := range a { |
| 824 | if a[i] != b[i] { |
| 825 | return false |
| 826 | } |
| 827 | } |
| 828 | return true |
| 829 | } |
| 830 |