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 · 27m ago 32290114
history blame raw 25.5 KiB
1package web
2
3// The browser's issue and change-request writes: filing, triaging, commenting,
4// approving, and closing. Each one is built by the same metadata writer the CLI
5// uses, so the file format and its validation cannot drift. The pre-receive hook
6// still governs pushes; these handlers check the same ACL capability before they
7// call a writer (capabilities.go).
8//
9// Every author and approval is taken from the session account, never from a form
10// field. That is the whole point of making the browser authenticate. The gate that
11// establishes it — session, body bound, CSRF, repository, rules — is beginWrite.
12
13import (
14 "errors"
15 "fmt"
16 "net/http"
17 "slices"
18 "strings"
19 "time"
20
21 "rodent.software/vole/ci/runs"
22 "rodent.software/vole/forge/access"
23 "rodent.software/vole/forge/index"
24 "rodent.software/vole/forge/landing"
25 "rodent.software/vole/forge/repo"
26 "rodent.software/vole/internal/format"
27 "rodent.software/vole/metadata"
28)
29
30func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
31 ctx, ok := w.beginWrite(rw, r, maxFormBody)
32 if !ok {
33 return
34 }
35 m := ctx.Repo
36 if !ctx.canDiscuss() {
37 w.refuse(rw, r, "you do not have permission to file issues here")
38 return
39 }
40 title := strings.TrimSpace(r.PostFormValue("title"))
41 body := strings.TrimSpace(r.PostFormValue("body"))
42 if title == "" {
43 http.Error(rw, "an issue needs a title", http.StatusBadRequest)
44 return
45 }
46 labels := normalizeLabelSet(r.PostForm["label"])
47 assignees := metadata.NormalizeAssignees(r.PostForm["assignee"])
48 if bad := unknownNames(assignees, w.assignableAccounts(m)); len(bad) > 0 {
49 http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest)
50 return
51 }
52 now := time.Now().UTC()
53 iss, err := metadata.NewIssue(title, body, ctx.Name, labels, assignees, now)
54 if err != nil {
55 http.Error(rw, err.Error(), http.StatusBadRequest)
56 return
57 }
58 if err := metadata.CreateIssue(m.Git(), iss, ctx.Name); err != nil {
59 w.serverError(rw, r, "cannot create the issue: "+err.Error())
60 return
61 }
62 w.audit("web.issue.create", ctx.Name, m.Name, iss.ID)
63 http.Redirect(rw, r, "/"+m.Name+"/issues/"+iss.ID, http.StatusSeeOther)
64}
65
66// SetIssueLabels replaces an issue's labels. Labels are shared state, but the
67// author may label their own issue; a maintainer may label any issue as triage.
68// The form sends labels as hidden inputs written by the chip UI.
69func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) {
70 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
71 if !ok {
72 return
73 }
74 m := ctx.Repo
75 id := r.PathValue("id")
76 iss, err := index.LoadIssue(m, id)
77 if err != nil {
78 w.notFound(rw, r)
79 return
80 }
81 if !ctx.canTriage(iss.Author) {
82 w.refuse(rw, r, "you do not have permission to edit these labels")
83 return
84 }
85 // A rejected write re-renders the sidebar section the control belongs to, so
86 // the reason appears beside it instead of replacing the page with text.
87 labelsView := func(msg string) (string, any) {
88 return "issue_labels", w.issueSidebar(r, m, iss, msg)
89 }
90 labels := normalizeLabelSet(r.PostForm["label"])
91 updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error {
92 if sameStrings(metadata.NormalizeLabels(iss.Labels), labels) {
93 return metadata.ErrNoChange
94 }
95 iss.Labels = labels
96 return nil
97 }, ctx.Name)
98 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
99 w.fail(rw, r, http.StatusBadRequest, "cannot update the labels: "+err.Error(), labelsView)
100 return
101 }
102 w.audit("web.issue.labels", ctx.Name, m.Name, id+" "+strings.Join(labels, ","))
103 if htmxRequest(r) {
104 if updated == nil {
105 updated = iss
106 }
107 w.render(rw, "issue_labels", w.issueSidebar(r, m, updated, ""), 0)
108 return
109 }
110 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
111}
112
113// SetIssueAssignees replaces an issue's assignees. Same rule as labels: the
114// author may manage their own issue, a maintainer may triage any issue.
115func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) {
116 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
117 if !ok {
118 return
119 }
120 m := ctx.Repo
121 id := r.PathValue("id")
122 iss, err := index.LoadIssue(m, id)
123 if err != nil {
124 w.notFound(rw, r)
125 return
126 }
127 if !ctx.canTriage(iss.Author) {
128 w.refuse(rw, r, "you do not have permission to edit these assignees")
129 return
130 }
131 assigneesView := func(msg string) (string, any) {
132 return "issue_assignees", w.issueSidebar(r, m, iss, msg)
133 }
134 assignees := metadata.NormalizeAssignees(r.PostForm["assignee"])
135 // A current assignee may have lost access since being assigned. Keep them in
136 // the allowed set so an edit can remove the others without silently dropping
137 // that name.
138 if bad := unknownNames(assignees, mergeNames(w.assignableAccounts(m), iss.Assignees)); len(bad) > 0 {
139 w.fail(rw, r, http.StatusBadRequest,
140 "unknown or unavailable assignee: "+strings.Join(bad, ", "), assigneesView)
141 return
142 }
143 updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error {
144 if sameStrings(metadata.NormalizeAssignees(iss.Assignees), assignees) {
145 return metadata.ErrNoChange
146 }
147 iss.Assignees = assignees
148 return nil
149 }, ctx.Name)
150 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
151 w.fail(rw, r, http.StatusBadRequest, "cannot update the assignees: "+err.Error(), assigneesView)
152 return
153 }
154 w.audit("web.issue.assignees", ctx.Name, m.Name, id+" "+strings.Join(assignees, ","))
155 if htmxRequest(r) {
156 if updated == nil {
157 updated = iss
158 }
159 w.render(rw, "issue_assignees", w.issueSidebar(r, m, updated, ""), 0)
160 return
161 }
162 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
163}
164
165func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
166 ctx, ok := w.beginWrite(rw, r, maxFormBody)
167 if !ok {
168 return
169 }
170 m := ctx.Repo
171 if !ctx.canApproveChange() {
172 w.refuse(rw, r, "you do not have permission to approve here")
173 return
174 }
175 id := r.PathValue("id")
176 req, err := w.requestAt(memoOf(r), m, id)
177 if err != nil {
178 w.notFound(rw, r)
179 return
180 }
181 if !req.Open() {
182 http.Error(rw, "this change is not open", http.StatusBadRequest)
183 return
184 }
185 if req.Author == ctx.Name {
186 http.Error(rw, "you cannot approve your own change", http.StatusForbidden)
187 return
188 }
189 sidebar := func(msg string) (string, any) {
190 return "request_live_write", w.requestLiveWrite(r, m, req, msg)
191 }
192 // The form carries the base and head the page displayed. If a push moved the
193 // followed branch, or the request was retargeted, the writer refuses rather than
194 // approving a target the approver never saw.
195 expectedBase := strings.TrimSpace(r.PostFormValue("base"))
196 expectedHead := strings.TrimSpace(r.PostFormValue("head"))
197 if expectedBase == "" || expectedHead == "" {
198 w.fail(rw, r, http.StatusBadRequest,
199 "reload the request before approving: the form did not name the base and head you reviewed", sidebar)
200 return
201 }
202 if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, expectedBase, expectedHead, time.Now().UTC(), ctx.Name); err != nil {
203 w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar)
204 return
205 }
206 w.audit("web.request.approve", ctx.Name, m.Name, id+" "+req.Head)
207 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
208 w.Logger("web: approval left the request tree invalid: %v", problems)
209 }
210 if htmxRequest(r) {
211 updated, err := w.requestAt(memoOf(r), m, id)
212 if err != nil {
213 w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, err.Error()), 0)
214 return
215 }
216 w.render(rw, "request_live_write", w.requestLiveWrite(r, m, updated, ""), 0)
217 return
218 }
219 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
220}
221
222// CreateChangeRequest is the browser counterpart of `vole change new`. The head
223// is a branch, not a free-form SHA, so a contributor cannot name a commit they
224// did not push. For a contributor the branch must be inside their own namespace;
225// for write and admin it may be any normal branch.
226func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
227 ctx, ok := w.beginWrite(rw, r, maxFormBody)
228 if !ok {
229 return
230 }
231 m := ctx.Repo
232 if !ctx.canOpenChangeRequest() {
233 w.refuse(rw, r, "you do not have permission to open changes here")
234 return
235 }
236 title := strings.TrimSpace(r.PostFormValue("title"))
237 body := strings.TrimSpace(r.PostFormValue("body"))
238 base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/")
239 head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/")
240 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
241 if title == "" {
242 http.Error(rw, "a change needs a title", http.StatusBadRequest)
243 return
244 }
245 if base == "" || base == metadataBranch || head == "" || head == metadataBranch {
246 http.Error(rw, "choose a base and a head branch", http.StatusBadRequest)
247 return
248 }
249 // The head starts at the branch tip and the request follows that branch afterwards.
250 // It has to be a real branch name, or a hand-edited form could store something the
251 // metadata reader would refuse.
252 if !m.Git().ValidBranch(head) {
253 http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest)
254 return
255 }
256 headRef := "refs/heads/" + head
257 if contributorOnly(ctx.ACL, ctx.Name) {
258 // A contributor's head must be a branch the hook would let them push. The
259 // namespace is the account name, which cannot change, so this agrees with
260 // the gate without a roster lookup.
261 if !access.IsContributorRef(ctx.Name, headRef) {
262 http.Error(rw, "contributors may only open requests from their own branch namespace", http.StatusForbidden)
263 return
264 }
265 }
266 headCommit, err := m.Git().RevParse(headRef)
267 if err != nil || headCommit == "" {
268 http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest)
269 return
270 }
271 if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" {
272 http.Error(rw, "that base branch does not exist", http.StatusBadRequest)
273 return
274 }
275 req, err := metadata.NewRequest(title, body, ctx.Name, base, headCommit, time.Now().UTC())
276 if err != nil {
277 http.Error(rw, err.Error(), http.StatusBadRequest)
278 return
279 }
280 req.HeadRef = head
281 // Issues this request promises to close. They are checked when the request is
282 // written, so a typo fails here rather than at the landing. A number is turned
283 // into the id here, the same way the CLI flag does, so the file holds ids.
284 if req.Closes, err = w.resolveCloses(m, closesField(r.PostFormValue("closes"))); err != nil {
285 http.Error(rw, err.Error(), http.StatusBadRequest)
286 return
287 }
288 if ctx.ACL.RequiresLinkedIssue() && len(req.Closes) == 0 {
289 http.Error(rw, "This repository requires at least one linked issue when a change is opened. Add an issue in Closes.", http.StatusBadRequest)
290 return
291 }
292 if err := metadata.CreateRequest(m.Git(), req, ctx.Name); err != nil {
293 w.serverError(rw, r, "cannot open the change: "+err.Error())
294 return
295 }
296 w.audit("web.request.create", ctx.Name, m.Name, req.ID+" "+headCommit)
297 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
298 w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems)
299 }
300 http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID, http.StatusSeeOther)
301}
302
303// ChangeIssueState closes or reopens an issue. The author can do this without a
304// maintainer, and a maintainer can do it to anybody's issue: closing your own
305// mistake should not need a second person, but triage is a maintainer's job.
306func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) {
307 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
308 if !ok {
309 return
310 }
311 m := ctx.Repo
312 id := r.PathValue("id")
313 state := strings.TrimSpace(r.PostFormValue("state"))
314 if state != "open" && state != "closed" {
315 http.Error(rw, "state must be open or closed", http.StatusBadRequest)
316 return
317 }
318 iss, err := index.LoadIssue(m, id)
319 if err != nil {
320 w.notFound(rw, r)
321 return
322 }
323 if !ctx.canTriage(iss.Author) {
324 w.refuse(rw, r, "you do not have permission to change this issue")
325 return
326 }
327 _, err = metadata.SetIssueState(m.Git(), id, state, ctx.Name)
328 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
329 w.serverError(rw, r, "cannot change the issue: "+err.Error())
330 return
331 }
332 w.audit("web.issue.state", ctx.Name, m.Name, id+" "+state)
333 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
334}
335
336// ChangeRequestState closes or reopens a change request. Closing is not merging:
337// it records that the proposal was withdrawn, and a closed request can be
338// reopened without losing its discussion or approvals.
339func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) {
340 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
341 if !ok {
342 return
343 }
344 m := ctx.Repo
345 id := r.PathValue("id")
346 req, err := w.requestAt(memoOf(r), m, id)
347 if err != nil {
348 w.notFound(rw, r)
349 return
350 }
351 if !ctx.canTriage(req.Author) {
352 w.refuse(rw, r, "you do not have permission to change this change")
353 return
354 }
355 state := strings.TrimSpace(r.PostFormValue("state"))
356 if state != "open" && state != "closed" {
357 http.Error(rw, "state must be open or closed", http.StatusBadRequest)
358 return
359 }
360 if _, err := metadata.SetRequestState(m.Git(), id, state, ctx.Name); err != nil {
361 w.serverError(rw, r, "cannot change the change: "+err.Error())
362 return
363 }
364 w.audit("web.request.state", ctx.Name, m.Name, id+" "+state)
365 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
366 w.Logger("web: request state %s left the request tree invalid: %v", id, problems)
367 }
368 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
369}
370
371// UpdateRequestTarget moves an open request's base and the branch it follows. The
372// request page posts both together because the two targets are one review surface;
373// changing either one makes approvals that named the old base and commit stale.
374//
375// A value that is already the request's target is kept even if its branch has since
376// been deleted: that lets a reader change only the other side without first having
377// to repair a missing branch. A value that is changing has to name a branch that
378// exists, and a contributor may only name a head branch in their own namespace.
379func (w *Web) UpdateRequestTarget(rw http.ResponseWriter, r *http.Request) {
380 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
381 if !ok {
382 return
383 }
384 m := ctx.Repo
385 id := r.PathValue("id")
386 req, err := w.requestAt(memoOf(r), m, id)
387 if err != nil {
388 w.notFound(rw, r)
389 return
390 }
391 if !ctx.canTriage(req.Author) {
392 w.refuse(rw, r, "you do not have permission to change this change's target")
393 return
394 }
395 if !req.Open() {
396 w.refuse(rw, r, "only an open change can change its target")
397 return
398 }
399
400 base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/")
401 head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/")
402 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
403 if base == "" || base == metadataBranch || !m.Git().ValidBranch(base) {
404 http.Error(rw, "choose the branch this change should land on", http.StatusBadRequest)
405 return
406 }
407 if base != req.Base {
408 if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" {
409 http.Error(rw, "that base branch does not exist", http.StatusBadRequest)
410 return
411 }
412 }
413
414 headTip, headRef := req.Head, req.HeadRef
415 switch {
416 case head == "":
417 // The picker showed a pinned commit and the reader did not choose a branch.
418 case head == req.HeadRef:
419 // The current branch: follow it if it still exists, otherwise keep the last
420 // tip. A reader who is only changing the base must not have to repair the
421 // head first.
422 if tip, err := m.Git().RevParse("refs/heads/" + head); err == nil && tip != "" {
423 headTip = tip
424 }
425 default:
426 if head == metadataBranch || !m.Git().ValidBranch(head) {
427 http.Error(rw, "choose the branch whose tip should be reviewed", http.StatusBadRequest)
428 return
429 }
430 ref := "refs/heads/" + head
431 if contributorOnly(ctx.ACL, ctx.Name) && !access.IsContributorRef(ctx.Name, ref) {
432 w.refuse(rw, r, "contributors may only propose a branch in their own namespace")
433 return
434 }
435 tip, err := m.Git().RevParse(ref)
436 if err != nil || tip == "" {
437 http.Error(rw, "that head branch does not exist; push it first", http.StatusBadRequest)
438 return
439 }
440 headTip, headRef = tip, head
441 }
442
443 if base != req.Base || headTip != req.Head || headRef != req.HeadRef {
444 if _, err := metadata.SetRequestTarget(m.Git(), id, base, headTip, headRef, ctx.Name); err != nil {
445 w.serverError(rw, r, "cannot update the change's target: "+err.Error())
446 return
447 }
448 w.audit("web.request.target", ctx.Name, m.Name,
449 id+" "+req.Base+"<-"+req.HeadRef+" -> "+base+"<-"+headRef)
450 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
451 w.Logger("web: request target %s left the request tree invalid: %v", id, problems)
452 }
453 }
454 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
455}
456
457// UpdateRequestIssues adds or removes one issue linked to an open change request.
458// The link is the promise landing closes it, so this goes through the same writer
459// as `vole change update --closes`: the issue has to exist and its id is stored.
460func (w *Web) UpdateRequestIssues(rw http.ResponseWriter, r *http.Request) {
461 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
462 if !ok {
463 return
464 }
465 m := ctx.Repo
466 id := r.PathValue("id")
467 req, err := w.requestAt(memoOf(r), m, id)
468 if err != nil {
469 w.notFound(rw, r)
470 return
471 }
472 if !req.Open() {
473 w.refuse(rw, r, "only an open change can link issues")
474 return
475 }
476 if !ctx.canTriage(req.Author) {
477 w.refuse(rw, r, "you do not have permission to change these links")
478 return
479 }
480 // A refused link re-renders the Issues section, so the reason appears beside
481 // the controls instead of replacing the review.
482 details := func(msg string) (string, any) {
483 v := w.requestSidebar(r, m, req, "", "")
484 v.IssuesError = msg
485 return "request_issues", v
486 }
487 action := strings.TrimSpace(r.PostFormValue("action"))
488 if action != "add" && action != "remove" {
489 w.fail(rw, r, http.StatusBadRequest, "Choose whether to link or remove the issue.", details)
490 return
491 }
492 ids, err := w.resolveCloses(m, closesField(r.PostFormValue("issue")))
493 if err != nil {
494 w.fail(rw, r, http.StatusBadRequest, err.Error(), details)
495 return
496 }
497 if len(ids) == 0 {
498 w.fail(rw, r, http.StatusBadRequest, "Type an issue number or id.", details)
499 return
500 }
501 if len(ids) > 1 {
502 w.fail(rw, r, http.StatusBadRequest, "Link one issue at a time.", details)
503 return
504 }
505 target := ids[0]
506 closes := append([]string(nil), req.Closes...)
507 switch action {
508 case "add":
509 if slices.Contains(closes, target) {
510 w.fail(rw, r, http.StatusBadRequest, "That issue is already linked.", details)
511 return
512 }
513 closes = append(closes, target)
514 case "remove":
515 i := slices.Index(closes, target)
516 if i < 0 {
517 w.fail(rw, r, http.StatusBadRequest, "That issue is not linked.", details)
518 return
519 }
520 closes = append(closes[:i], closes[i+1:]...)
521 }
522 updated, err := metadata.SetRequestCloses(m.Git(), id, closes, ctx.Name)
523 if err != nil {
524 w.fail(rw, r, http.StatusBadRequest, "cannot update the linked issues: "+err.Error(), details)
525 return
526 }
527 w.audit("web.request.issues", ctx.Name, m.Name, id+" "+action+" "+target)
528 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
529 w.Logger("web: request links %s left the request tree invalid: %v", id, problems)
530 }
531 if htmxRequest(r) {
532 if updated == nil {
533 updated = req
534 }
535 w.render(rw, "request_issues", w.requestSidebar(r, m, updated, "", ""), 0)
536 return
537 }
538 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
539}
540
541// landGate is the landing gate wired to the stores this process already holds. The
542// run queue is the web's own open handle, not a path: the server is long-lived and
543// the gate must not close it.
544func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate {
545 return landing.Gate{
546 Repo: m.Git(), Name: m.Name, ACL: acl, Actor: actor, Role: acl.RoleFor(actor),
547 Runs: func() (*runs.Store, error) { return w.Runs, nil },
548 Audit: func(action, actor, subject, detail string) {
549 w.audit("web."+action, actor, subject, detail)
550 },
551 }
552}
553
554// MergeRequest lands a change request in the browser: the same operation
555// `vole change merge` runs, through the same gate the pre-receive hook applies to a
556// push. The branch moves here, so without that call this handler would be the
557// easiest way to land an unapproved change.
558//
559// When the combined result is still building, the click is not thrown away: it arms
560// the request to land as soon as the result is green. Every landing still runs the
561// full gate, so an arm is a wait, not an authorisation.
562func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) {
563 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
564 if !ok {
565 return
566 }
567 m := ctx.Repo
568 if !ctx.canMergeChange() {
569 w.refuse(rw, r, "you do not have permission to merge here")
570 return
571 }
572 id := r.PathValue("id")
573 req, err := w.requestAt(memoOf(r), m, id)
574 if err != nil {
575 w.notFound(rw, r)
576 return
577 }
578 if !req.Open() {
579 http.Error(rw, "this change is not open", http.StatusBadRequest)
580 return
581 }
582 now := time.Now().UTC()
583 gate := w.landGate(m, ctx.ACL, ctx.Name)
584
585 // Publishing and queueing here makes the click useful even when the reconciler
586 // has not seen this head yet. A failure is not fatal: Prepare below reports the
587 // real state, and the background pass will try again.
588 if w.EnsureCandidate != nil {
589 if err := w.EnsureCandidate(m.Name, id); err != nil && w.Logger != nil {
590 w.Logger("web: cannot prepare the combined result for %s: %v", id, err)
591 }
592 }
593
594 plan, err := landing.Prepare(gate, id, ctx.Name, now)
595 if err != nil {
596 w.mergeRefused(rw, r, m, req, err.Error())
597 return
598 }
599 if len(plan.Conflicts) > 0 {
600 w.mergeRefused(rw, r, m, req, fmt.Sprintf(
601 "%s does not merge into %s: %s conflict. Resolve it on the branch and push again",
602 format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", ")))
603 return
604 }
605 if plan.Blocked != "" {
606 w.mergeRefused(rw, r, m, req, plan.Blocked)
607 return
608 }
609 if !plan.Ready {
610 if plan.Pending || len(plan.Gaps) == 0 {
611 w.armMerge(rw, r, m, req, ctx.Name, now, "Merging when the combined result passes.")
612 return
613 }
614 w.mergeRefused(rw, r, m, req, "The combined result failed:\n "+strings.Join(plan.Gaps, "\n "))
615 return
616 }
617
618 _, err = landing.Land(gate, id, ctx.Name, now)
619 if err != nil {
620 // A base move between the plan and the landing is the expected race. If the
621 // new plan is merely pending, arm instead of making the reader click again.
622 if next, perr := landing.Prepare(gate, id, ctx.Name, time.Now().UTC()); perr == nil &&
623 len(next.Conflicts) == 0 && next.Blocked == "" && !next.Ready &&
624 (next.Pending || len(next.Gaps) == 0) {
625 w.armMerge(rw, r, m, req, ctx.Name, now, "The latest base moved, so Vole is testing the new combination. It will land when that passes.")
626 return
627 }
628 // The refusal is the answer, so it is shown as written: it names the missing
629 // approval, the failing check, or the conflicting path. It is shown *on the
630 // page* rather than as bare text, so the reader keeps the conflict block and
631 // the diff that explain it.
632 w.mergeRefused(rw, r, m, req, err.Error())
633 return
634 }
635 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
636 w.Logger("web: merge left the request tree invalid: %v", problems)
637 }
638 // The redirect shows the request page, which now reads as merged.
639 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
640}
641
642// armMerge records "merge when ready" and answers the click. The intent is a wait,
643// not an authorisation: the worker re-runs the whole gate before it moves a ref.
644func (w *Web) armMerge(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, actor string, now time.Time, notice string) {
645 if w.Runs == nil {
646 w.mergeRefused(rw, r, m, req, "this instance has no CI queue, so the combined result cannot be tested here")
647 return
648 }
649 if err := w.Runs.ArmAutoMerge(m.Name, req.ID, actor, now); err != nil {
650 w.serverError(rw, r, "cannot arm merge: "+err.Error())
651 return
652 }
653 w.audit("web.request.merge_when_ready", actor, m.Name, req.ID)
654 if htmxRequest(r) {
655 view := w.requestLiveWrite(r, m, req, "")
656 view.Notice = notice
657 w.render(rw, "request_live_write", view, 0)
658 return
659 }
660 http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID+"?merging=1", http.StatusSeeOther)
661}
662
663// CancelMerge turns off merge-when-ready. It cannot cancel a landing that already
664// happened: the row is gone by then, and the redirect shows the merged page.
665func (w *Web) CancelMerge(rw http.ResponseWriter, r *http.Request) {
666 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
667 if !ok {
668 return
669 }
670 if !ctx.canMergeChange() {
671 w.refuse(rw, r, "you do not have permission to merge here")
672 return
673 }
674 m := ctx.Repo
675 id := r.PathValue("id")
676 if w.Runs == nil {
677 http.Error(rw, "this instance has no CI queue", http.StatusBadRequest)
678 return
679 }
680 if err := w.Runs.DisarmAutoMerge(m.Name, id); err != nil {
681 w.serverError(rw, r, "cannot cancel merge-when-ready: "+err.Error())
682 return
683 }
684 w.audit("web.request.merge_cancel", ctx.Name, m.Name, id)
685 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
686}
687
688// mergeRefused renders a refusal where the reader can see the review, the conflict
689// block and the controls that explain it.
690func (w *Web) mergeRefused(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, msg string) {
691 if htmxRequest(r) {
692 w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, msg), 0)
693 return
694 }
695 w.renderRequestPage(rw, r, msg, http.StatusConflict)
696}
697
698// closesField splits the closes input: spaces, commas and newlines all work, because
699// the field is one line somebody pasted issue ids into.
700func closesField(raw string) []string {
701 return strings.FieldsFunc(raw, func(r rune) bool {
702 return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
703 })
704}
705