Sign in

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

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 25m ago 32290114
history blame raw 26.6 KiB
1package metadata
2
3import (
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.
16func 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
37func 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.
78func 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.
102func 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.
111func 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.
139func 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.
159func 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.
200func 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.
238func 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.
276func 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.
325func 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.
356func 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.
392func 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.
439func 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.
485func 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.
560func 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.
596func 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
608func 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.
624func 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.
659func 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.
696func 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.
730func 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.
758func 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.
801func 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
819func 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