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.

Blame · metadata/request_write.go

Line attribution on main · 829 line(s)

c5902451
Keenan Nemetz
16d ago
1 package metadata
1eeca4e6
Keenan Nemetz
18d ago
2
3 import (
38b30251
Keenan Nemetz
13d ago
4 "errors"
1eeca4e6
Keenan Nemetz
18d ago
5 "fmt"
307cbc2a
Keenan Nemetz
17d ago
6 "strings"
1eeca4e6
Keenan Nemetz
18d ago
7 "time"
8
d37d301f
Keenan Nemetz
19h ago
9 "rodent.software/vole/core/git"
10 voleid "rodent.software/vole/core/id"
11 "rodent.software/vole/internal/format"
1eeca4e6
Keenan Nemetz
18d ago
12 )
13
14 // New builds a request with a fresh ULID. Nothing is assigned server-side, so
15 // concurrent authors never contend.
62576fa8
Keenan Nemetz
14d ago
16 func NewRequest(title, body, author, base, head string, now time.Time) (*Request, error) {
c5902451
Keenan Nemetz
16d ago
17 id, err := voleid.New(now)
1eeca4e6
Keenan Nemetz
18d ago
18 if err != nil {
19 return nil, err
20 }
21 r := &Request{
22 ID: id,
307cbc2a
Keenan Nemetz
17d ago
23 Title: strings.TrimSpace(title),
24 Author: strings.TrimSpace(author),
1eeca4e6
Keenan Nemetz
18d ago
25 Base: base,
26 Head: head,
27 State: "open",
28 Created: now.UTC(),
29 Description: body,
30 }
c5902451
Keenan Nemetz
16d ago
31 if err := validateRequestForWrite(r); err != nil {
1eeca4e6
Keenan Nemetz
18d ago
32 return nil, err
33 }
34 return r, nil
35 }
36
c5902451
Keenan Nemetz
16d ago
37 func validateRequestForWrite(r *Request) error {
1eeca4e6
Keenan Nemetz
18d ago
38 if r.Title == "" {
39 return fmt.Errorf("requests: title is required")
40 }
c5902451
Keenan Nemetz
16d ago
41 if len(r.Title) > maxRequestTitleLen {
42 return fmt.Errorf("requests: title is %d bytes, limit %d", len(r.Title), maxRequestTitleLen)
1eeca4e6
Keenan Nemetz
18d ago
43 }
44 if r.Author == "" {
45 return fmt.Errorf("requests: author is required")
46 }
4581690d
Keenan Nemetz
13d ago
47 if problems := bodyProblems("body", r.Description); len(problems) > 0 {
48 return fmt.Errorf("requests: %s", problems[0])
49 }
62576fa8
Keenan Nemetz
14d ago
50 if !assigneeRE.MatchString(r.Author) {
51 return fmt.Errorf("requests: author %q is not an account name", r.Author)
31df8bae
Keenan Nemetz
17d ago
52 }
e050e14a
Keenan Nemetz
13d ago
53 if !validBranchName(r.Base) {
1eeca4e6
Keenan Nemetz
18d ago
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 }
8e1005eb
Keenan Nemetz
13d ago
59 if r.HeadRef != "" {
e050e14a
Keenan Nemetz
13d ago
60 if !validBranchName(r.HeadRef) {
8e1005eb
Keenan Nemetz
13d ago
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 }
2841d922
Keenan Nemetz
13d ago
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 }
183d48e5
Keenan Nemetz
13d ago
70 if _, problems := parseCloses(r.Closes, "request"); len(problems) > 0 {
71 return fmt.Errorf("%s", strings.Join(problems, "; "))
72 }
1eeca4e6
Keenan Nemetz
18d ago
73 return nil
74 }
75
307cbc2a
Keenan Nemetz
17d ago
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) {
c5902451
Keenan Nemetz
16d ago
79 d := NewDoc()
307cbc2a
Keenan Nemetz
17d ago
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)
8e1005eb
Keenan Nemetz
13d ago
85 if r.HeadRef != "" {
86 d.Word("head_ref", r.HeadRef)
87 }
183d48e5
Keenan Nemetz
13d ago
88 d.List("closes", r.Closes)
307cbc2a
Keenan Nemetz
17d ago
89 d.Word("created", r.Created.UTC().Format(time.RFC3339))
38b30251
Keenan Nemetz
13d ago
90 if r.State == "merged" {
2841d922
Keenan Nemetz
13d ago
91 if r.MergedBase != "" {
92 d.Word("merged_base", r.MergedBase)
93 }
38b30251
Keenan Nemetz
13d ago
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 }
307cbc2a
Keenan Nemetz
17d ago
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) {
c5902451
Keenan Nemetz
16d ago
103 d := NewDoc()
307cbc2a
Keenan Nemetz
17d ago
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("")
1eeca4e6
Keenan Nemetz
18d ago
108 }
109
110 // Create writes a new request onto the metadata branch.
c5902451
Keenan Nemetz
16d ago
111 func CreateRequest(g *git.Repo, r *Request, actor string) error {
112 if err := validateRequestForWrite(r); err != nil {
1eeca4e6
Keenan Nemetz
18d ago
113 return err
114 }
307cbc2a
Keenan Nemetz
17d ago
115 raw, err := renderRequest(r)
1eeca4e6
Keenan Nemetz
18d ago
116 if err != nil {
117 return err
118 }
b492b557
Keenan Nemetz
3d ago
119 base := fmt.Sprintf("%s/%s/", ChangesDir, r.ID)
c5902451
Keenan Nemetz
16d ago
120 _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error {
b492b557
Keenan Nemetz
3d ago
121 if _, exists := files[base+changeName]; exists {
1eeca4e6
Keenan Nemetz
18d ago
122 return fmt.Errorf("requests: %s already exists", r.ID)
123 }
122cbe4f
Keenan Nemetz
7d ago
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 }
b492b557
Keenan Nemetz
3d ago
131 files[base+changeName] = raw
1eeca4e6
Keenan Nemetz
18d ago
132 return nil
133 }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor)
134 return err
135 }
136
c9202f7f
Keenan Nemetz
10d ago
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.
c5902451
Keenan Nemetz
16d ago
139 func GetRequest(g *git.Repo, id string) (*Request, error) {
c9202f7f
Keenan Nemetz
10d ago
140 commit, err := g.RevParse(Ref)
1eeca4e6
Keenan Nemetz
18d ago
141 if err != nil {
142 return nil, err
143 }
c9202f7f
Keenan Nemetz
10d ago
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)
1eeca4e6
Keenan Nemetz
18d ago
150 }
c9202f7f
Keenan Nemetz
10d ago
151 return req, nil
1eeca4e6
Keenan Nemetz
18d ago
152 }
153
a4e7a99a
Keenan Nemetz
6d ago
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
b492b557
Keenan Nemetz
3d ago
162 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
a4e7a99a
Keenan Nemetz
6d ago
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 }
b492b557
Keenan Nemetz
3d ago
181 files[base+changeName] = raw
a4e7a99a
Keenan Nemetz
6d ago
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
e050e14a
Keenan Nemetz
13d ago
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.
53ff967b
Keenan Nemetz
13d ago
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.
e050e14a
Keenan Nemetz
13d ago
200 func SetRequestHead(g *git.Repo, id, head, headRef, actor string) (*Request, error) {
1eeca4e6
Keenan Nemetz
18d ago
201 var out *Request
b492b557
Keenan Nemetz
3d ago
202 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
e050e14a
Keenan Nemetz
13d ago
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 }
c5902451
Keenan Nemetz
16d ago
207 _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error {
1eeca4e6
Keenan Nemetz
18d ago
208 r, err := loadFrom(files, id)
209 if err != nil {
210 return err
211 }
53ff967b
Keenan Nemetz
13d ago
212 if r.State != "open" {
213 return fmt.Errorf("requests: %s is %s, so its head cannot move", id, r.State)
214 }
1eeca4e6
Keenan Nemetz
18d ago
215 r.Head = head
e050e14a
Keenan Nemetz
13d ago
216 r.HeadRef = headRef
c5902451
Keenan Nemetz
16d ago
217 if err := validateRequestForWrite(r); err != nil {
1eeca4e6
Keenan Nemetz
18d ago
218 return err
219 }
307cbc2a
Keenan Nemetz
17d ago
220 raw, err := renderRequest(r)
1eeca4e6
Keenan Nemetz
18d ago
221 if err != nil {
222 return err
223 }
b492b557
Keenan Nemetz
3d ago
224 files[base+changeName] = raw
1eeca4e6
Keenan Nemetz
18d ago
225 out = r
226 return nil
e050e14a
Keenan Nemetz
13d ago
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
b492b557
Keenan Nemetz
3d ago
240 dir := fmt.Sprintf("%s/%s/", ChangesDir, id)
e050e14a
Keenan Nemetz
13d ago
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 }
b492b557
Keenan Nemetz
3d ago
261 files[dir+changeName] = raw
e050e14a
Keenan Nemetz
13d ago
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 }
1eeca4e6
Keenan Nemetz
18d ago
268 return out, err
269 }
270
41d1fee9
Keenan Nemetz
13d ago
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
b492b557
Keenan Nemetz
3d ago
278 dir := fmt.Sprintf("%s/%s/", ChangesDir, id)
41d1fee9
Keenan Nemetz
13d ago
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 }
b492b557
Keenan Nemetz
3d ago
305 files[dir+changeName] = raw
41d1fee9
Keenan Nemetz
13d ago
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
e050e14a
Keenan Nemetz
13d ago
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.
e535ca55
Keenan Nemetz
4d ago
332 n, err := followBranch(files, branch, head)
e050e14a
Keenan Nemetz
13d ago
333 if err != nil {
e535ca55
Keenan Nemetz
4d ago
334 return err
335 }
336 updated = n
337 if n == 0 {
e050e14a
Keenan Nemetz
13d ago
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
e535ca55
Keenan Nemetz
4d ago
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 {
b492b557
Keenan Nemetz
3d ago
362 if strings.HasPrefix(path, ChangesDir+"/") {
e535ca55
Keenan Nemetz
4d ago
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 }
b492b557
Keenan Nemetz
3d ago
380 files[fmt.Sprintf("%s/%s/%s", ChangesDir, r.ID, changeName)] = raw
e535ca55
Keenan Nemetz
4d ago
381 updated++
382 }
383 return updated, nil
384 }
385
1eeca4e6
Keenan Nemetz
18d ago
386 // Approve records principal's approval of the request's current head and base.
e050e14a
Keenan Nemetz
13d ago
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) {
62576fa8
Keenan Nemetz
14d ago
393 if !assigneeRE.MatchString(principal) {
394 return nil, fmt.Errorf("requests: %q is not an account name", principal)
1eeca4e6
Keenan Nemetz
18d ago
395 }
396 var out *Request
b492b557
Keenan Nemetz
3d ago
397 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
c5902451
Keenan Nemetz
16d ago
398 _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error {
1eeca4e6
Keenan Nemetz
18d ago
399 r, err := loadFrom(files, id)
400 if err != nil {
401 return err
402 }
e050e14a
Keenan Nemetz
13d ago
403 if r.State != "open" {
404 return fmt.Errorf("requests: %s is %s, so it cannot be approved", id, r.State)
405 }
62576fa8
Keenan Nemetz
14d ago
406 if r.Author == principal {
31df8bae
Keenan Nemetz
17d ago
407 return fmt.Errorf("requests: the author cannot approve their own request")
1eeca4e6
Keenan Nemetz
18d ago
408 }
409 if r.Head == "" {
410 return fmt.Errorf("requests: %s has no head commit to approve", id)
411 }
e050e14a
Keenan Nemetz
13d ago
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 }
307cbc2a
Keenan Nemetz
17d ago
422 raw, err := renderApproval(Approval{
423 Principal: principal,
424 Base: r.Base,
425 Head: r.Head,
426 Created: now.UTC(),
427 })
1eeca4e6
Keenan Nemetz
18d ago
428 if err != nil {
429 return err
430 }
307cbc2a
Keenan Nemetz
17d ago
431 files[fmt.Sprintf("%s%s/%s.md", base, approvalDir, principal)] = raw
1eeca4e6
Keenan Nemetz
18d ago
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.
c5902451
Keenan Nemetz
16d ago
439 func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) {
38b30251
Keenan Nemetz
13d ago
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.
1eeca4e6
Keenan Nemetz
18d ago
444 switch state {
38b30251
Keenan Nemetz
13d ago
445 case "open", "closed":
1eeca4e6
Keenan Nemetz
18d ago
446 default:
447 return nil, fmt.Errorf("requests: state must be open or closed")
448 }
449 var out *Request
b492b557
Keenan Nemetz
3d ago
450 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
c5902451
Keenan Nemetz
16d ago
451 _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error {
1eeca4e6
Keenan Nemetz
18d ago
452 r, err := loadFrom(files, id)
453 if err != nil {
454 return err
455 }
456 r.State = state
307cbc2a
Keenan Nemetz
17d ago
457 raw, err := renderRequest(r)
1eeca4e6
Keenan Nemetz
18d ago
458 if err != nil {
459 return err
460 }
b492b557
Keenan Nemetz
3d ago
461 files[base+changeName] = raw
1eeca4e6
Keenan Nemetz
18d ago
462 out = r
463 return nil
464 }, fmt.Sprintf("request: %s %s", id, state), actor)
465 return out, err
466 }
467
38b30251
Keenan Nemetz
13d ago
468 // MarkMerged records a landing: the state becomes merged and the request names the
2841d922
Keenan Nemetz
13d ago
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.
38b30251
Keenan Nemetz
13d ago
478 //
e050e14a
Keenan Nemetz
13d ago
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 //
38b30251
Keenan Nemetz
13d ago
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.
e050e14a
Keenan Nemetz
13d ago
485 func MarkMerged(g *git.Repo, id, landedBase, landedHead, reviewedHead, actor string, at time.Time) (*Request, []string, error) {
38b30251
Keenan Nemetz
13d ago
486 if !shaRE.MatchString(landedHead) {
183d48e5
Keenan Nemetz
13d ago
487 return nil, nil, fmt.Errorf("requests: merged head must be a full commit id, got %q", landedHead)
38b30251
Keenan Nemetz
13d ago
488 }
2841d922
Keenan Nemetz
13d ago
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 }
e050e14a
Keenan Nemetz
13d ago
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 }
38b30251
Keenan Nemetz
13d ago
495 var (
183d48e5
Keenan Nemetz
13d ago
496 out *Request
497 closed []string
38b30251
Keenan Nemetz
13d ago
498 )
b492b557
Keenan Nemetz
3d ago
499 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
38b30251
Keenan Nemetz
13d ago
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 }
2841d922
Keenan Nemetz
13d ago
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 {
38b30251
Keenan Nemetz
13d ago
509 out = r
510 return ErrNoChange
511 }
512 r.State = "merged"
e050e14a
Keenan Nemetz
13d ago
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 }
2841d922
Keenan Nemetz
13d ago
519 r.MergedBase = landedBase
38b30251
Keenan Nemetz
13d ago
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 }
b492b557
Keenan Nemetz
3d ago
527 files[base+changeName] = raw
183d48e5
Keenan Nemetz
13d ago
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
e535ca55
Keenan Nemetz
4d ago
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 }
38b30251
Keenan Nemetz
13d ago
544 out = r
545 return nil
546 }, fmt.Sprintf("request: %s merged %s", id, landedHead), actor)
547 if errors.Is(err, ErrNoChange) {
183d48e5
Keenan Nemetz
13d ago
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 }
38b30251
Keenan Nemetz
13d ago
569 }
183d48e5
Keenan Nemetz
13d ago
570 issues, err := ParseIssues(contents)
38b30251
Keenan Nemetz
13d ago
571 if err != nil {
183d48e5
Keenan Nemetz
13d ago
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)
38b30251
Keenan Nemetz
13d ago
591 }
183d48e5
Keenan Nemetz
13d ago
592 return closed, nil
38b30251
Keenan Nemetz
13d ago
593 }
594
7c6e52e7
Keenan Nemetz
17d ago
595 // NewComment builds a comment on a request with a fresh id.
c5902451
Keenan Nemetz
16d ago
596 func NewRequestComment(body, author string, now time.Time) (*Comment, error) {
597 commentID, err := voleid.New(now)
7c6e52e7
Keenan Nemetz
17d ago
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 }
4581690d
Keenan Nemetz
13d ago
612 if problems := bodyProblems("comment", c.Body); len(problems) > 0 {
613 return fmt.Errorf("requests: %s", problems[0])
614 }
7c6e52e7
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
624 func AddRequestComment(g *git.Repo, requestID string, c *Comment, actor string) error {
7c6e52e7
Keenan Nemetz
17d ago
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 }
c5902451
Keenan Nemetz
16d ago
634 raw, err := RenderComment(c)
7c6e52e7
Keenan Nemetz
17d ago
635 if err != nil {
636 return err
637 }
c5902451
Keenan Nemetz
16d ago
638 path := RequestCommentPath(requestID, c.ID)
639 _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error {
f6f4b1db
Keenan Nemetz
15d ago
640 r, err := loadFrom(files, requestID)
641 if err != nil {
7c6e52e7
Keenan Nemetz
17d ago
642 return err
643 }
f6f4b1db
Keenan Nemetz
15d ago
644 if prob := checkCommentReply(r.Comments, c); prob != "" {
645 return fmt.Errorf("requests: %s", prob)
646 }
7c6e52e7
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
659 func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) (*Comment, error) {
122cbe4f
Keenan Nemetz
7d ago
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
7c6e52e7
Keenan Nemetz
17d ago
682 }
683 files[path] = raw
122cbe4f
Keenan Nemetz
7d ago
684 out = c
685 return actorOr(actor, c.Author), nil
686 }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID))
7c6e52e7
Keenan Nemetz
17d ago
687 if err != nil {
688 return nil, err
689 }
122cbe4f
Keenan Nemetz
7d ago
690 return out, nil
7c6e52e7
Keenan Nemetz
17d ago
691 }
692
f6f4b1db
Keenan Nemetz
15d ago
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.
c5902451
Keenan Nemetz
16d ago
696 func DeleteRequestComment(g *git.Repo, requestID, commentID, actor string) (*Comment, error) {
122cbe4f
Keenan Nemetz
7d ago
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
7c6e52e7
Keenan Nemetz
17d ago
717 }
f6f4b1db
Keenan Nemetz
15d ago
718 files[path] = raw
122cbe4f
Keenan Nemetz
7d ago
719 out = c
720 return actorOr(actor, c.Author), nil
721 }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID))
7c6e52e7
Keenan Nemetz
17d ago
722 if err != nil {
723 return nil, err
724 }
122cbe4f
Keenan Nemetz
7d ago
725 return out, nil
7c6e52e7
Keenan Nemetz
17d ago
726 }
727
1eeca4e6
Keenan Nemetz
18d ago
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 }
c5902451
Keenan Nemetz
16d ago
735 list, err := ParseRequests(contents)
1eeca4e6
Keenan Nemetz
18d ago
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 }
bdf6a0de
Keenan Nemetz
13d ago
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
b492b557
Keenan Nemetz
3d ago
764 base := fmt.Sprintf("%s/%s/", ChangesDir, id)
bdf6a0de
Keenan Nemetz
13d ago
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 }
122cbe4f
Keenan Nemetz
7d ago
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 }
bdf6a0de
Keenan Nemetz
13d ago
783 r.Closes = closes
784 raw, err := renderRequest(r)
785 if err != nil {
786 return err
787 }
b492b557
Keenan Nemetz
3d ago
788 files[base+changeName] = raw
bdf6a0de
Keenan Nemetz
13d ago
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
122cbe4f
Keenan Nemetz
7d ago
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
bdf6a0de
Keenan Nemetz
13d ago
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 }