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 · 28m ago 32290114
history blame raw 28.6 KiB
1package web
2
3// The conversation page of a change request: the description, the discussion, and
4// the sidebar that says whether it can land. The list and the new-change form are
5// in requests.go; the Files tab is request_files.go.
6
7import (
8 "html/template"
9 "net/http"
10 "sort"
11 "strings"
12 "time"
13 "unicode/utf8"
14
15 "rodent.software/vole/ci/runs"
16 "rodent.software/vole/core/git"
17 "rodent.software/vole/forge/access"
18 "rodent.software/vole/forge/index"
19 "rodent.software/vole/forge/landing"
20 "rodent.software/vole/forge/repo"
21 "rodent.software/vole/metadata"
22)
23
24// RequestView renders one change request: what it would land, what changed, and
25// which approvals currently apply.
26type requestApproval struct {
27 metadata.Approval
28 // Name is the approver's account name; the file itself is keyed by that name.
29 Name string
30 // Current is false when the approval names a commit or base that is no longer
31 // what the request asks for, which is the normal state after a force-push.
32 Current bool
33 // Counts is Current plus the approver still being an active account that
34 // maintains the repository. The file stays on the branch after a retirement or
35 // a demotion, so it can be current and still not count; the gate uses this same
36 // rule, which is why the page cannot promise a merge the gate refuses.
37 Counts bool
38}
39
40// currentApprovals counts the approvals the landing gate would accept: the ones
41// naming this exact base and head, written by an account that is still active and
42// still maintains the repository. It is the single place the page and the gate
43// agree on a number.
44func currentApprovals(acl *access.ACL, req *metadata.Request) int {
45 n := 0
46 for _, a := range req.Approvals {
47 if a.Head == req.Head && a.Base == req.Base && acl.EligibleApprover(a.Principal) {
48 n++
49 }
50 }
51 return n
52}
53
54// branchOption is one choice in the request target picker. The selected flag is
55// built here so the template does not have to compare strings, and so a branch that
56// has gone missing can stay visible as the current value instead of silently
57// selecting a different branch.
58type branchOption struct {
59 Name string
60 Selected bool
61}
62
63func branchOptions(names []string, selected string) []branchOption {
64 out := make([]branchOption, 0, len(names)+1)
65 found := false
66 for _, name := range names {
67 if name == selected {
68 found = true
69 }
70 out = append(out, branchOption{Name: name, Selected: name == selected})
71 }
72 if selected != "" && !found {
73 out = append([]branchOption{{Name: selected, Selected: true}}, out...)
74 }
75 return out
76}
77
78// requestCI is the approval a contributor branch needs before its workflow may
79// run. It belongs on the request because that is where a maintainer reviews the
80// code the tip contains.
81type requestCI struct {
82 Ref string
83 CanAllow bool
84}
85
86// requestLandingView holds the landing facts the Review and Changes sections use:
87// whether the branch still merges, which issues it closes, and how much it
88// changes. The sidebar embeds it because Review carries the merge check; a write
89// that renders only the sidebar can leave it zero, and the next full or polling
90// render fills it in.
91type requestLandingView struct {
92 FileCount int
93 Added int
94 Removed int
95 AlreadyMerged bool
96 Conflicts []string
97 MergeChecked bool
98}
99
100// requestIssue is one issue linked to a change request. It carries the same facts
101// the overview's recent-issue rows show, plus the id the remove control posts.
102type requestIssue struct {
103 ID string
104 Number int
105 Title string
106 Author string
107 Created time.Time
108}
109
110// requestSidebarView is the data the change sidebar fragments need. One type for
111// the full page and the HTMX replacements, so an approval or state change can
112// refresh the whole sidebar without rebuilding the rest of the review.
113type requestSidebarView struct {
114 Page
115 requestLandingView
116 Name string
117 Request *metadata.Request
118 // Number is the `#42` alias, derived from creation order in history.
119 Number int
120 // LinkedIssues is the Issues section: the issues landing this change closes.
121 LinkedIssues []requestIssue
122 Approvals []requestApproval
123 Checks []*runs.Run
124 Required int
125 Current int
126 CanApprove bool
127 CanClose bool
128 // CanEdit is the title and description control. It is the author's or a
129 // maintainer's, the same rule as the writer and the push boundary; unlike the
130 // target it does not depend on the request still being open.
131 CanEdit bool
132 // CanEditIssues is the add and remove controls. Only an open request may be
133 // relinked, and only by its author or a maintainer, matching `change update`.
134 CanEditIssues bool
135 // IssuesError is the reason a link change did not happen, shown beside the
136 // controls. It is separate from Error because a link write replaces only the
137 // Issues section.
138 IssuesError string
139 // HeadRefNote says when the branch the request follows no longer points at the
140 // commit under review ("branch moved", "branch deleted"). A push normally moves
141 // the head with the branch, so "branch moved" means that sync did not happen and
142 // the approval control is withheld until the request catches up.
143 HeadRefNote string
144 // CanRetarget is the combined base/head control. It is offered to the author and
145 // to maintainers, and only while the request is open: a merged request's target is
146 // the record of what landed. A request opened from a branch follows it already;
147 // this is for changing which branch, or attaching a request opened from a commit.
148 CanRetarget bool
149 // BaseOptions and HeadOptions are the branches the picker may name. HeadOptions
150 // is narrowed to the viewer's own namespace for a contributor.
151 BaseOptions []branchOption
152 HeadOptions []branchOption
153 // CanMerge is the merge control: true when the caller may merge and the gate
154 // would accept the request's approvals. The button also covers "merge when
155 // ready", so a combined result that is still running does not hide it.
156 CanMerge bool
157 // MergePending is true when the only thing between the request and a landing is
158 // a combined result that is queued or running.
159 MergePending bool
160 // MergeArmed is true when a merger asked for the request to land as soon as the
161 // combined result passes.
162 MergeArmed bool
163 // MergeBlocked is the gate's reason when the caller may merge in principle but
164 // the request cannot land (missing approval, failing check, conflict).
165 MergeBlocked string
166 // CombinedNeeded is true when landing needs a merge commit, so there is a tree
167 // that only exists as a combined result.
168 CombinedNeeded bool
169 // CombinedSHA is that commit and CombinedRuns are the newest runs recorded for
170 // it. CombinedState is passed, testing, failed or conflict.
171 CombinedSHA string
172 CombinedRuns []*runs.Run
173 CombinedState string
174 // Notice is a non-error message a write wants beside the controls.
175 Notice string
176 CanDiscuss bool
177 // Live says this render is an out-of-band update to the data-only regions,
178 // not a page or a targeted section swap. Each live region adds hx-swap-oob so
179 // one poll can update several places without replacing a form or a picker.
180 Live bool
181 ContributorCI *requestCI
182 // Details is the in-place edit state. A full page leaves it zero; an HTMX
183 // edit response fills it so the title and description come back in the mode
184 // the reader left them in.
185 Details detailsEdit
186 Error string
187}
188
189// withLanding returns the same sidebar with the landing facts a full page or a
190// poll has already computed. The write handlers do not compute them, so their
191// fragment keeps the section's other state and the next render fills the rest.
192func (v requestSidebarView) withLanding(l requestLandingView) requestSidebarView {
193 v.requestLandingView = l
194 return v
195}
196
197// requestLiveWrite is the status data marked so every region that belongs to the
198// live group carries hx-swap-oob. The response is a set of targeted updates, not a
199// replacement for the sidebar: a write can update the status regions without
200// loading or throwing away an issue search or a target picker.
201func (w *Web) requestLiveWrite(r *http.Request, m *repo.Meta, req *metadata.Request, errMsg string) requestSidebarView {
202 v := w.requestStatus(r, m, req, errMsg).withLanding(w.requestLanding(m, req))
203 v.Live = true
204 return v
205}
206
207// requestPageView is the full change page: the sidebar plus the conversation.
208type requestPageView struct {
209 requestSidebarView
210 Tab string
211 Description template.HTML
212 CommentTarget *commentTarget
213 LineCommentURL string
214 Thread threadView
215}
216
217// requestStatus computes the data the live regions and the page actions use:
218// approvals, checks, landing facts, merge state, and the capabilities that decide
219// which controls exist. It deliberately leaves out the stateful regions — the
220// branch picker, the linked-issue list, and the issue numbering — so a poll does
221// not load the data only an editor needs.
222func (w *Web) requestStatus(r *http.Request, m *repo.Meta, req *metadata.Request, errMsg string) requestSidebarView {
223 acl, _ := m.ACL()
224 approvals := make([]requestApproval, 0, len(req.Approvals))
225 current := 0
226 for _, a := range req.Approvals {
227 c := a.Head == req.Head && a.Base == req.Base
228 counts := c && acl != nil && acl.EligibleApprover(a.Principal)
229 if counts {
230 current++
231 }
232 approvals = append(approvals, requestApproval{
233 Approval: a, Name: w.who(a.Principal), Current: c, Counts: counts,
234 })
235 }
236 required := 0
237 if acl != nil {
238 required = acl.RequiredApprovals()
239 }
240 var checks []*runs.Run
241 if w.Runs != nil && req.Head != "" {
242 if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil {
243 for _, run := range res {
244 checks = append(checks, run)
245 }
246 sort.Slice(checks, func(i, j int) bool {
247 if checks[i].Workflow != checks[j].Workflow {
248 return checks[i].Workflow < checks[j].Workflow
249 }
250 return checks[i].Job < checks[j].Job
251 })
252 }
253 }
254 who := w.accountName(r)
255 canClose := canTriage(acl, who, req.Author)
256 headRefNote := ""
257 if req.HeadRef != "" {
258 switch tip, err := m.Git().RevParse("refs/heads/" + req.HeadRef); {
259 case err != nil || tip == "":
260 headRefNote = "branch deleted"
261 case tip != req.Head:
262 headRefNote = "branch moved"
263 }
264 }
265 // A sync that did not happen leaves the previous head in place. Do not offer to
266 // approve it while the followed branch is known to be ahead: the page is showing
267 // an older tip than the branch, and the head control is the way to catch up.
268 approvable := req.Open() && canApproveRequest(acl, who, req.Author) && headRefNote != "branch moved"
269 canRetarget := req.Open() && canTriage(acl, who, req.Author)
270 // Preflight the landing rather than guessing from the approval count: the same
271 // gate the handler calls is asked here, so the button and the write agree about
272 // approvals, checks and conflicts.
273 var mergeable, mergePending, mergeArmed bool
274 var mergeBlocked string
275 var combinedNeeded bool
276 var combinedSHA, combinedState string
277 var combinedRuns []*runs.Run
278 showPlan := canMerge(acl, who) ||
279 (acl != nil && acl.RequiresChecks() && acl.IsProtected("refs/heads/"+req.Base))
280 if req.Open() && showPlan {
281 // Prepare publishes the combined result when one is needed. It is a derived
282 // ref, not review state, so it is safe to make on a read; the reconciler
283 // does the same work in the background.
284 plan, err := landing.Prepare(w.landGate(m, acl, who), req.ID, who, time.Now().UTC())
285 switch {
286 case err != nil:
287 if canMerge(acl, who) {
288 mergeBlocked = err.Error()
289 }
290 case plan != nil:
291 combinedNeeded = plan.MergeCommit && plan.Checks
292 combinedSHA = plan.NewTip
293 switch {
294 case len(plan.Conflicts) > 0:
295 combinedState = "conflict"
296 case !plan.MergeCommit:
297 combinedState = ""
298 case !plan.Checks:
299 combinedState = "passed"
300 case len(plan.Gaps) == 0:
301 combinedState = "passed"
302 case plan.Pending:
303 combinedState = "testing"
304 default:
305 combinedState = "failed"
306 }
307 if w.Runs != nil && plan.NewTip != "" && plan.MergeCommit {
308 if res, err := w.Runs.ForSHA(m.Name, plan.NewTip); err == nil {
309 for _, run := range res {
310 combinedRuns = append(combinedRuns, run)
311 }
312 sort.Slice(combinedRuns, func(i, j int) bool {
313 if combinedRuns[i].Workflow != combinedRuns[j].Workflow {
314 return combinedRuns[i].Workflow < combinedRuns[j].Workflow
315 }
316 return combinedRuns[i].Job < combinedRuns[j].Job
317 })
318 }
319 }
320 if canMerge(acl, who) {
321 switch {
322 case plan.Blocked != "":
323 mergeBlocked = plan.Blocked
324 case len(plan.Conflicts) > 0:
325 mergeBlocked = "" // the Review section already names the paths
326 default:
327 mergeable = true
328 if !plan.Ready {
329 if plan.Pending || len(plan.Gaps) == 0 {
330 mergePending = true
331 } else {
332 mergeBlocked = "The combined result failed. " +
333 "The runs below name the job; pushing a fix starts a new review."
334 }
335 }
336 }
337 }
338 // Someone is looking at this request, so a missing combined result is
339 // work worth queueing even before the first approval. This is the
340 // "recently viewed" half of the base-move rule; the reconciler handles
341 // the head push. Anonymous and read-only viewers do not get to spend
342 // runner time by loading a page.
343 if plan.MergeCommit && len(plan.Gaps) > 0 && w.EnsureCandidate != nil && w.canDiscuss(r, acl) {
344 if err := w.EnsureCandidate(m.Name, req.ID); err != nil && w.Logger != nil {
345 w.Logger("web: cannot prepare the combined result for %s: %v", req.ID, err)
346 }
347 }
348 }
349 }
350 if req.Open() && canMerge(acl, who) && w.Runs != nil {
351 if arm, err := w.Runs.AutoMerge(m.Name, req.ID); err == nil && arm != nil {
352 mergeArmed = true
353 }
354 }
355 var contributorCI *requestCI
356 if req.Open() {
357 if ref := w.pendingContributorCI(m, req.Head); ref != "" {
358 contributorCI = &requestCI{
359 Ref: strings.TrimPrefix(ref, "refs/heads/"),
360 CanAllow: canApprove(acl, who),
361 }
362 }
363 }
364 return requestSidebarView{
365 Page: w.page(r, ""), Name: m.Name, Request: req,
366 Approvals: approvals, Checks: checks, Required: required, Current: current,
367 CanApprove: approvable, CanClose: canClose, HeadRefNote: headRefNote,
368 CanEdit: canClose,
369 CanEditIssues: req.Open() && canClose,
370 CanRetarget: canRetarget,
371 CanMerge: mergeable, MergePending: mergePending, MergeArmed: mergeArmed,
372 MergeBlocked: mergeBlocked,
373 CombinedNeeded: combinedNeeded, CombinedSHA: combinedSHA,
374 CombinedRuns: combinedRuns, CombinedState: combinedState,
375 CanDiscuss: w.canDiscuss(r, acl),
376 ContributorCI: contributorCI,
377 Error: errMsg,
378 }
379}
380
381// requestSidebar adds the stateful regions to requestStatus: the branch picker's
382// options, the change number, and the linked-issue rows. Only a full page or a
383// write that belongs to one of those regions needs them. The section names the
384// tab the page is showing, and is empty for the conversation, so the document
385// title leads with the change and then names where the reader is.
386func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, section, errMsg string) requestSidebarView {
387 v := w.requestStatus(r, m, req, errMsg)
388 if v.CanRetarget {
389 acl, _ := m.ACL()
390 who := w.accountName(r)
391 branches := branchNames(m)
392 v.BaseOptions = branchOptions(branches, req.Base)
393 v.HeadOptions = branchOptions(headBranches(branches, acl, who), req.HeadRef)
394 }
395 nums := w.entityNumbers(memoOf(r), m)
396 v.Number = nums[req.ID]
397 v.Title = numberedTitle(v.Number, req.Title, section, m.Name)
398 v.LinkedIssues = w.requestLinkedIssues(memoOf(r), m, req, nums)
399 return v
400}
401
402// requestLinkedIssues reads the issues a request promises to close. The index
403// supplies the rows the overview uses, and a row the index cannot name is read
404// from the issue itself rather than dropped: the request still holds the link.
405func (w *Web) requestLinkedIssues(memo *requestMemo, m *repo.Meta, req *metadata.Request, nums map[string]int) []requestIssue {
406 if len(req.Closes) == 0 {
407 return nil
408 }
409 rows := make(map[string]index.IssueRow, len(req.Closes))
410 if w.Index != nil {
411 if err := w.ensureIndex(memo, m); err == nil {
412 if list, err := w.Index.Issues(m.ID, index.Filter{State: "all"}); err == nil {
413 for _, row := range list {
414 rows[row.ID] = row
415 }
416 }
417 }
418 }
419 out := make([]requestIssue, 0, len(req.Closes))
420 for _, id := range req.Closes {
421 if row, ok := rows[id]; ok {
422 out = append(out, requestIssue{
423 ID: row.ID, Number: row.Number, Title: row.Title,
424 Author: row.Author, Created: row.Created,
425 })
426 continue
427 }
428 iss, err := index.LoadIssue(m, id)
429 if err != nil {
430 out = append(out, requestIssue{ID: id, Number: nums[id], Title: "Linked issue unavailable"})
431 continue
432 }
433 out = append(out, requestIssue{
434 ID: id, Number: nums[id], Title: iss.Title,
435 Author: iss.Author, Created: iss.Created,
436 })
437 }
438 return out
439}
440
441// requestIssueSearchLimit is how many matches the link picker offers. It is a
442// picker beside the linked list, not the issue list: more than a handful makes the
443// sidebar something to scroll before it is something to choose from.
444const requestIssueSearchLimit = 8
445
446// requestIssueSearchView is the result fragment under the link search box. It is
447// separate from the Issues section so typing replaces only the results.
448type requestIssueSearchView struct {
449 Page
450 Name string
451 Request *metadata.Request
452 Query string
453 Results []requestIssue
454 Error string
455}
456
457// SearchRequestIssues filters the issues the link picker may add. A term matches
458// a title, a `#42` number, or a ULID prefix; the linked issues are left out
459// because offering one again would only produce a refusal.
460func (w *Web) SearchRequestIssues(rw http.ResponseWriter, r *http.Request) {
461 m, ok := w.open(rw, r)
462 if !ok {
463 return
464 }
465 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
466 if err != nil {
467 w.notFound(rw, r)
468 return
469 }
470 query := strings.TrimSpace(r.URL.Query().Get("q"))
471 view := requestIssueSearchView{
472 Page: w.page(r, ""), Name: m.Name, Request: req, Query: query,
473 }
474 if query == "" || !req.Open() {
475 // An empty field closes the picker: an empty fragment removes the
476 // popover instead of leaving a blank panel under the search box.
477 return
478 }
479 if utf8.RuneCountInString(query) > 200 {
480 view.Error = "Search is limited to 200 characters."
481 w.render(rw, "request_issue_search", view, 0)
482 return
483 }
484 if w.Index == nil {
485 view.Error = "Issue search is unavailable right now."
486 w.render(rw, "request_issue_search", view, 0)
487 return
488 }
489 if err := w.ensureIndex(memoOf(r), m); err != nil {
490 if w.Logger != nil {
491 w.Logger("web: issue search index for %s: %v", m.Name, err)
492 }
493 view.Error = "Issue search is unavailable right now."
494 w.render(rw, "request_issue_search", view, 0)
495 return
496 }
497 linked := make(map[string]bool, len(req.Closes))
498 for _, id := range req.Closes {
499 linked[id] = true
500 }
501 // Ask for the linked issues too, then drop them: a title shared by a linked
502 // issue must not fill the picker with rows that cannot be added.
503 rows, err := w.Index.SearchIssues(m.ID, query, requestIssueSearchLimit+len(linked))
504 if err != nil {
505 if w.Logger != nil {
506 w.Logger("web: issue search for %s: %v", m.Name, err)
507 }
508 view.Error = "Issue search is unavailable right now."
509 w.render(rw, "request_issue_search", view, 0)
510 return
511 }
512 for _, row := range rows {
513 if linked[row.ID] {
514 continue
515 }
516 view.Results = append(view.Results, requestIssue{
517 ID: row.ID, Number: row.Number, Title: row.Title,
518 Author: row.Author, Created: row.Created,
519 })
520 if len(view.Results) == requestIssueSearchLimit {
521 break
522 }
523 }
524 w.render(rw, "request_issue_search", view, 0)
525}
526
527// pendingContributorCI finds the contributor branch whose tip is head and whose
528// CI still waits for a maintainer. It is keyed by the commit: a branch that has
529// moved past this request is not what is under review here.
530func (w *Web) pendingContributorCI(m *repo.Meta, head string) string {
531 if w.Runs == nil || head == "" {
532 return ""
533 }
534 refs, err := m.Git().ListRefs("refs/heads/")
535 if err != nil {
536 return ""
537 }
538 var candidates []string
539 for ref, tip := range refs {
540 if tip != head {
541 continue
542 }
543 if _, ok := access.ContributorRefName(ref); ok {
544 candidates = append(candidates, ref)
545 }
546 }
547 sort.Strings(candidates)
548 for _, ref := range candidates {
549 allowed, err := w.Runs.CIAllowed(m.Name, ref, head)
550 if err == nil && !allowed {
551 return ref
552 }
553 }
554 return ""
555}
556
557// requestDiscussionView is the comment thread fragment, separate from the
558// sidebar so posting a comment swaps only the conversation.
559// requestThread is the discussion section of a change request. It is the same
560// fragment an issue renders; the change adds a line anchor, and keeps its tab on
561// the URL after a post.
562func (w *Web) requestThread(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) threadView {
563 return w.requestThreadFrom(r, m, req, target, errMsg, w.requestComments(m, req))
564}
565
566// requestThreadFrom is requestThread for a caller that already has the comments:
567// the page needs the same list to mark the diff lines they are anchored to.
568func (w *Web) requestThreadFrom(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string, views []commentView) threadView {
569 return w.thread(r, m, threadView{
570 Name: m.Name,
571 Section: "request-discussion",
572 Composer: "request-comment-form",
573 PostURL: "/" + m.Name + "/changes/" + req.ID + "/comments",
574 PushURL: "/" + m.Name + "/changes/" + req.ID,
575 BackURL: "/" + m.Name + "/changes/" + req.ID,
576 Title: "Discussion",
577 EmptyText: "No discussion yet.",
578 Topic: req.ID,
579 Target: target,
580 ThreadError: errMsg,
581 }, views)
582}
583
584func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
585 w.renderRequestPage(rw, r, "", http.StatusOK)
586}
587
588// RequestLive serves the out-of-band updates for the change's data-only regions.
589// The poll element keeps its own DOM (and every form, search, and picker inside
590// it); the response only names the regions that can change behind the reader's
591// back.
592func (w *Web) RequestLive(rw http.ResponseWriter, r *http.Request) {
593 m, ok := w.open(rw, r)
594 if !ok {
595 return
596 }
597 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
598 if err != nil {
599 w.notFound(rw, r)
600 return
601 }
602 view := w.requestStatus(r, m, req, "").withLanding(w.requestLanding(m, req))
603 view.Live = true
604 w.render(rw, "request_live", view, 0)
605}
606
607// requestLanding asks the two questions a reviewer has before approving: does the
608// branch still merge, and how much does it change? A diff says what changed; it
609// cannot say whether the branch still merges. merge-tree answers that in memory,
610// so the reviewer sees "this needs a rebase" before anybody approves — and the
611// answer is only claimed when the question could actually be asked (base branch
612// present, head reachable), because a page that says "clean" when it did not
613// check is worse than one that says nothing.
614func (w *Web) requestLanding(m *repo.Meta, req *metadata.Request) requestLandingView {
615 return w.landingFrom(m, req, w.reviewDiff(m, req))
616}
617
618// landingFrom is requestLanding for a caller that already has the inventory: the
619// full page needs the same file list for its tab count, so it must not read it
620// twice.
621func (w *Web) landingFrom(m *repo.Meta, req *metadata.Request, change reviewChange) requestLandingView {
622 added, removed := diffSummary(change.Files)
623 var conflicts []string
624 mergeChecked := false
625 if req.Open() && req.Head != "" {
626 if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil && tip != "" {
627 if paths, err := m.Git().MergeConflicts(tip, req.Head); err == nil {
628 conflicts, mergeChecked = paths, true
629 }
630 }
631 }
632 return requestLandingView{
633 FileCount: len(change.Files), Added: added, Removed: removed,
634 AlreadyMerged: change.Merged, Conflicts: conflicts, MergeChecked: mergeChecked,
635 }
636}
637
638// renderRequestPage is the request page, optionally with a message at the top and a
639// status of its own. A write that is refused for a reason the page can explain
640// re-renders here rather than answering with bare text: the reader keeps the review,
641// the conflict block and the controls, and the message sits beside them.
642func (w *Web) renderRequestPage(rw http.ResponseWriter, r *http.Request, errMsg string, status int) {
643 m, ok := w.open(rw, r)
644 if !ok {
645 return
646 }
647 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
648 if err != nil {
649 w.notFound(rw, r)
650 return
651 }
652
653 comments := w.requestComments(m, req)
654 sidebar := w.requestSidebar(r, m, req, "", errMsg).
655 withLanding(w.landingFrom(m, req, w.reviewDiff(m, req)))
656 sidebar.Details.Inline = true
657 if r.URL.Query().Get("merging") == "1" {
658 sidebar.Notice = "Merging when the combined result passes. Use Cancel merge to stop waiting."
659 }
660 w.render(rw, "request", requestPageView{
661 requestSidebarView: sidebar,
662 Tab: "conversation",
663 Description: w.renderMarkdown(m.Name, req.Description),
664 CommentTarget: commentTargetFromQuery(r),
665 LineCommentURL: "/" + m.Name + "/changes/" + req.ID,
666 Thread: w.requestThreadFrom(r, m, req, commentTargetFromQuery(r), "", comments),
667 }, status)
668}
669
670// reviewChange is the inventory of what a change request changes: the range git
671// is asked about, every file it touches with its status and counts, and whether
672// the head has already landed. It is not a patch. The Files tab asks for the
673// hunks of the one file it shows, so listing a change costs a walk of the trees
674// and nothing is cut to fit a page.
675type reviewChange struct {
676 From, To string
677 Files []diffFile
678 Merged bool
679}
680
681// reviewDiff lists what a change request changes and says whether it has already
682// been merged, which is what the changed-file tree, the line counts and the
683// landing summary read.
684//
685// While the request is open that is the three-dot range against the branch it
686// targets. Once the head is in the base, though, that diff is empty by definition
687// — the merge base of the two is the head itself — so the landing record is used
688// instead: merged_base is the tip the request landed on, which is the "before"
689// side of the change that landed.
690func (w *Web) reviewDiff(m *repo.Meta, req *metadata.Request) reviewChange {
691 change := reviewChange{}
692 if req.Head == "" {
693 return change
694 }
695 change.From = req.Base + "..." + req.Head
696 landed := req.State == "merged"
697 if !landed {
698 landed, _ = m.Git().IsAncestor(req.Head, req.Base)
699 }
700 if landed {
701 change.Merged = true
702 change.From, change.To = mergedDiffRange(m.Git(), req)
703 }
704 if change.From == "" {
705 return change
706 }
707 changes, err := m.Git().DiffInventory(change.From, change.To)
708 if err != nil {
709 // A change whose files cannot be listed is shown without them rather than
710 // as an error page: the discussion, the landing and the merge controls
711 // still work, and the count says nothing instead of something wrong.
712 w.Logger("request %s: inventory: %v", req.ID, err)
713 return change
714 }
715 change.Files = changedFiles(changes)
716 return change
717}
718
719// mergedDiffRange is the "before" and "after" of a landing, or two empty strings
720// when the record cannot say. merged_base is exactly the old side of the change. A
721// request merged before that field existed names no base, but a landing that was a
722// merge commit still has one in its first parent; a fast-forward landing's head is
723// the branch's own commit, so its first parent is the branch history and not the
724// base.
725func mergedDiffRange(g *git.Repo, req *metadata.Request) (from, to string) {
726 landing := req.Head
727 if req.MergedHead != "" {
728 landing = req.MergedHead
729 }
730 if req.MergedBase != "" {
731 return req.MergedBase, landing
732 }
733 if landing == req.Head {
734 return "", ""
735 }
736 if parents, err := g.CommitParents(landing); err == nil && len(parents) > 1 {
737 return parents[0], landing
738 }
739 return "", ""
740}
741
742// reviewPathFor maps an anchor's path to the file row a review URL should open.
743// A comment on the old side of a rename names the old path, while the Files tree
744// keys the change by its new path.
745func (w *Web) reviewPathFor(m *repo.Meta, req *metadata.Request, path string) string {
746 for _, f := range w.reviewDiff(m, req).Files {
747 if f.NewPath == path || f.OldPath == path {
748 return diffFilePath(f)
749 }
750 }
751 return path
752}
753
754// attachRequestComments puts each current anchored comment beneath the line it
755// names. A comment on a deleted line belongs to the file as it stood at the
756// merge base; stale comments stay in the discussion and lose their line note.
757func attachRequestComments(files []diffFile, comments []commentView) {
758 for i := range files {
759 f := &files[i]
760 for h := range f.Hunks {
761 lines := f.Hunks[h].Lines
762 for l := range lines {
763 for _, rc := range comments {
764 if rc.Anchor == nil || rc.Deleted || !rc.Current {
765 continue
766 }
767 path, no := f.NewPath, lines[l].NewNo
768 if rc.Anchor.Side == "old" {
769 path, no = f.OldPath, lines[l].OldNo
770 }
771 if path == rc.Anchor.Path && no == rc.Anchor.Line {
772 lines[l].Comments = append(lines[l].Comments, diffComment{
773 ID: rc.ID, Author: rc.Author, Created: rc.Created, Body: rc.Body,
774 })
775 }
776 }
777 }
778 }
779 }
780}
781