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 · server/web/request_view.go

Line attribution on main · 780 line(s)

c717d4eb
Keenan Nemetz
13d ago
1 package 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
7 import (
8 "html/template"
9 "net/http"
10 "sort"
11 "strings"
097b2110
Keenan Nemetz
13d ago
12 "time"
838f282e
Keenan Nemetz
10d ago
13 "unicode/utf8"
c717d4eb
Keenan Nemetz
13d ago
14
d37d301f
Keenan Nemetz
19h ago
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"
c717d4eb
Keenan Nemetz
13d ago
22 )
23
24 // RequestView renders one change request: what it would land, what changed, and
25 // which approvals currently apply.
26 type 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
62b59a76
Keenan Nemetz
13d ago
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.
44 func 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
c717d4eb
Keenan Nemetz
13d ago
52 }
53
41d1fee9
Keenan Nemetz
13d ago
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.
58 type branchOption struct {
59 Name string
60 Selected bool
61 }
62
63 func 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
c717d4eb
Keenan Nemetz
13d ago
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.
81 type requestCI struct {
82 Ref string
83 CanAllow bool
84 }
85
ad4d9348
Keenan Nemetz
11d ago
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.
91 type requestLandingView struct {
92 FileCount int
93 Added int
94 Removed int
95 AlreadyMerged bool
96 Conflicts []string
97 MergeChecked bool
98 }
99
cad93cfe
Keenan Nemetz
10d ago
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.
102 type requestIssue struct {
103 ID string
104 Number int
105 Title string
106 Author string
107 Created time.Time
108 }
109
c717d4eb
Keenan Nemetz
13d ago
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.
113 type requestSidebarView struct {
114 Page
ad4d9348
Keenan Nemetz
11d ago
115 requestLandingView
e05f1c48
Keenan Nemetz
13d ago
116 Name string
117 Request *metadata.Request
118 // Number is the `#42` alias, derived from creation order in history.
119 Number int
267feec2
Keenan Nemetz
10d ago
120 // LinkedIssues is the Issues section: the issues landing this change closes.
cad93cfe
Keenan Nemetz
10d ago
121 LinkedIssues []requestIssue
122 Approvals []requestApproval
123 Checks []*runs.Run
124 Required int
125 Current int
126 CanApprove bool
127 CanClose bool
a4e7a99a
Keenan Nemetz
6d ago
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
cad93cfe
Keenan Nemetz
10d ago
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
267feec2
Keenan Nemetz
10d ago
135 // IssuesError is the reason a link change did not happen, shown beside the
cad93cfe
Keenan Nemetz
10d ago
136 // controls. It is separate from Error because a link write replaces only the
267feec2
Keenan Nemetz
10d ago
137 // Issues section.
138 IssuesError string
e050e14a
Keenan Nemetz
13d ago
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.
8e1005eb
Keenan Nemetz
13d ago
143 HeadRefNote string
41d1fee9
Keenan Nemetz
13d ago
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
e5478085
Keenan Nemetz
11d ago
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.
38b30251
Keenan Nemetz
13d ago
156 CanMerge bool
e5478085
Keenan Nemetz
11d ago
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
38b30251
Keenan Nemetz
13d ago
163 // MergeBlocked is the gate's reason when the caller may merge in principle but
e5478085
Keenan Nemetz
11d ago
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.
707dfd10
Keenan Nemetz
10d ago
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
c717d4eb
Keenan Nemetz
13d ago
181 ContributorCI *requestCI
802e0e52
Keenan Nemetz
5d ago
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
c717d4eb
Keenan Nemetz
13d ago
187 }
188
ad4d9348
Keenan Nemetz
11d ago
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.
192 func (v requestSidebarView) withLanding(l requestLandingView) requestSidebarView {
193 v.requestLandingView = l
194 return v
195 }
196
1b1fa7a5
Keenan Nemetz
10d ago
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.
707dfd10
Keenan Nemetz
10d ago
201 func (w *Web) requestLiveWrite(r *http.Request, m *repo.Meta, req *metadata.Request, errMsg string) requestSidebarView {
de84c32b
Keenan Nemetz
6d ago
202 v := w.requestStatus(r, m, req, errMsg).withLanding(w.requestLanding(m, req))
707dfd10
Keenan Nemetz
10d ago
203 v.Live = true
204 return v
205 }
206
207 // requestPageView is the full change page: the sidebar plus the conversation.
c717d4eb
Keenan Nemetz
13d ago
208 type requestPageView struct {
209 requestSidebarView
210 Tab string
211 Description template.HTML
212 CommentTarget *commentTarget
213 LineCommentURL string
214 Thread threadView
d3dcf54d
Keenan Nemetz
11d ago
215 }
216
1b1fa7a5
Keenan Nemetz
10d ago
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.
de84c32b
Keenan Nemetz
6d ago
222 func (w *Web) requestStatus(r *http.Request, m *repo.Meta, req *metadata.Request, errMsg string) requestSidebarView {
c717d4eb
Keenan Nemetz
13d ago
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
62b59a76
Keenan Nemetz
13d ago
228 counts := c && acl != nil && acl.EligibleApprover(a.Principal)
229 if counts {
c717d4eb
Keenan Nemetz
13d ago
230 current++
231 }
62b59a76
Keenan Nemetz
13d ago
232 approvals = append(approvals, requestApproval{
233 Approval: a, Name: w.who(a.Principal), Current: c, Counts: counts,
234 })
c717d4eb
Keenan Nemetz
13d ago
235 }
236 required := 0
237 if acl != nil {
238 required = acl.RequiredApprovals()
239 }
d3dcf54d
Keenan Nemetz
11d ago
240 var checks []*runs.Run
c717d4eb
Keenan Nemetz
13d ago
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 {
d3dcf54d
Keenan Nemetz
11d ago
244 checks = append(checks, run)
c717d4eb
Keenan Nemetz
13d ago
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)
8e1005eb
Keenan Nemetz
13d ago
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 }
e050e14a
Keenan Nemetz
13d ago
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"
41d1fee9
Keenan Nemetz
13d ago
269 canRetarget := req.Open() && canTriage(acl, who, req.Author)
38b30251
Keenan Nemetz
13d ago
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.
e5478085
Keenan Nemetz
11d ago
273 var mergeable, mergePending, mergeArmed bool
38b30251
Keenan Nemetz
13d ago
274 var mergeBlocked string
e5478085
Keenan Nemetz
11d ago
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.
097b2110
Keenan Nemetz
13d ago
284 plan, err := landing.Prepare(w.landGate(m, acl, who), req.ID, who, time.Now().UTC())
285 switch {
286 case err != nil:
e5478085
Keenan Nemetz
11d ago
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
38b30251
Keenan Nemetz
13d ago
353 }
354 }
c717d4eb
Keenan Nemetz
13d ago
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{
de84c32b
Keenan Nemetz
6d ago
365 Page: w.page(r, ""), Name: m.Name, Request: req,
1b1fa7a5
Keenan Nemetz
10d ago
366 Approvals: approvals, Checks: checks, Required: required, Current: current,
8e1005eb
Keenan Nemetz
13d ago
367 CanApprove: approvable, CanClose: canClose, HeadRefNote: headRefNote,
a4e7a99a
Keenan Nemetz
6d ago
368 CanEdit: canClose,
cad93cfe
Keenan Nemetz
10d ago
369 CanEditIssues: req.Open() && canClose,
1b1fa7a5
Keenan Nemetz
10d ago
370 CanRetarget: canRetarget,
371 CanMerge: mergeable, MergePending: mergePending, MergeArmed: mergeArmed,
e5478085
Keenan Nemetz
11d ago
372 MergeBlocked: mergeBlocked,
373 CombinedNeeded: combinedNeeded, CombinedSHA: combinedSHA,
374 CombinedRuns: combinedRuns, CombinedState: combinedState,
c717d4eb
Keenan Nemetz
13d ago
375 CanDiscuss: w.canDiscuss(r, acl),
376 ContributorCI: contributorCI,
377 Error: errMsg,
378 }
379 }
380
1b1fa7a5
Keenan Nemetz
10d ago
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
de84c32b
Keenan Nemetz
6d ago
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.
386 func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, section, errMsg string) requestSidebarView {
387 v := w.requestStatus(r, m, req, errMsg)
1b1fa7a5
Keenan Nemetz
10d ago
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 }
73ec800b
Keenan Nemetz
10d ago
395 nums := w.entityNumbers(memoOf(r), m)
1b1fa7a5
Keenan Nemetz
10d ago
396 v.Number = nums[req.ID]
de84c32b
Keenan Nemetz
6d ago
397 v.Title = numberedTitle(v.Number, req.Title, section, m.Name)
73ec800b
Keenan Nemetz
10d ago
398 v.LinkedIssues = w.requestLinkedIssues(memoOf(r), m, req, nums)
1b1fa7a5
Keenan Nemetz
10d ago
399 return v
400 }
401
cad93cfe
Keenan Nemetz
10d ago
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.
73ec800b
Keenan Nemetz
10d ago
405 func (w *Web) requestLinkedIssues(memo *requestMemo, m *repo.Meta, req *metadata.Request, nums map[string]int) []requestIssue {
cad93cfe
Keenan Nemetz
10d ago
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 {
73ec800b
Keenan Nemetz
10d ago
411 if err := w.ensureIndex(memo, m); err == nil {
cad93cfe
Keenan Nemetz
10d ago
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
838f282e
Keenan Nemetz
10d ago
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.
444 const 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.
448 type 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.
460 func (w *Web) SearchRequestIssues(rw http.ResponseWriter, r *http.Request) {
461 m, ok := w.open(rw, r)
462 if !ok {
463 return
464 }
5d440e37
Keenan Nemetz
10d ago
465 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
838f282e
Keenan Nemetz
10d ago
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() {
bb541fde
Keenan Nemetz
10d ago
475 // An empty field closes the picker: an empty fragment removes the
476 // popover instead of leaving a blank panel under the search box.
838f282e
Keenan Nemetz
10d ago
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 }
73ec800b
Keenan Nemetz
10d ago
489 if err := w.ensureIndex(memoOf(r), m); err != nil {
838f282e
Keenan Nemetz
10d ago
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
c717d4eb
Keenan Nemetz
13d ago
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.
530 func (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.
562 func (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.
568 func (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",
ed03b3d1
Keenan Nemetz
6h ago
573 PostURL: "/" + m.Name + "/changes/" + req.ID + "/comments",
574 PushURL: "/" + m.Name + "/changes/" + req.ID,
575 BackURL: "/" + m.Name + "/changes/" + req.ID,
c717d4eb
Keenan Nemetz
13d ago
576 Title: "Discussion",
577 EmptyText: "No discussion yet.",
578 Topic: req.ID,
579 Target: target,
580 ThreadError: errMsg,
581 }, views)
582 }
583
584 func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
097b2110
Keenan Nemetz
13d ago
585 w.renderRequestPage(rw, r, "", http.StatusOK)
586 }
587
707dfd10
Keenan Nemetz
10d ago
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.
592 func (w *Web) RequestLive(rw http.ResponseWriter, r *http.Request) {
c717d4eb
Keenan Nemetz
13d ago
593 m, ok := w.open(rw, r)
594 if !ok {
595 return
596 }
5d440e37
Keenan Nemetz
10d ago
597 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
c717d4eb
Keenan Nemetz
13d ago
598 if err != nil {
599 w.notFound(rw, r)
600 return
601 }
de84c32b
Keenan Nemetz
6d ago
602 view := w.requestStatus(r, m, req, "").withLanding(w.requestLanding(m, req))
707dfd10
Keenan Nemetz
10d ago
603 view.Live = true
604 w.render(rw, "request_live", view, 0)
d3dcf54d
Keenan Nemetz
11d ago
605 }
c717d4eb
Keenan Nemetz
13d ago
606
d3dcf54d
Keenan Nemetz
11d ago
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.
614 func (w *Web) requestLanding(m *repo.Meta, req *metadata.Request) requestLandingView {
f64c77e4
Keenan Nemetz
9d ago
615 return w.landingFrom(m, req, w.reviewDiff(m, req))
d3dcf54d
Keenan Nemetz
11d ago
616 }
c717d4eb
Keenan Nemetz
13d ago
617
f64c77e4
Keenan Nemetz
9d ago
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.
621 func (w *Web) landingFrom(m *repo.Meta, req *metadata.Request, change reviewChange) requestLandingView {
622 added, removed := diffSummary(change.Files)
c717d4eb
Keenan Nemetz
13d ago
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 }
d3dcf54d
Keenan Nemetz
11d ago
632 return requestLandingView{
f64c77e4
Keenan Nemetz
9d ago
633 FileCount: len(change.Files), Added: added, Removed: removed,
634 AlreadyMerged: change.Merged, Conflicts: conflicts, MergeChecked: mergeChecked,
d3dcf54d
Keenan Nemetz
11d ago
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.
642 func (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 }
5d440e37
Keenan Nemetz
10d ago
647 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
d3dcf54d
Keenan Nemetz
11d ago
648 if err != nil {
649 w.notFound(rw, r)
650 return
651 }
652
653 comments := w.requestComments(m, req)
de84c32b
Keenan Nemetz
6d ago
654 sidebar := w.requestSidebar(r, m, req, "", errMsg).
f64c77e4
Keenan Nemetz
9d ago
655 withLanding(w.landingFrom(m, req, w.reviewDiff(m, req)))
802e0e52
Keenan Nemetz
5d ago
656 sidebar.Details.Inline = true
e5478085
Keenan Nemetz
11d ago
657 if r.URL.Query().Get("merging") == "1" {
658 sidebar.Notice = "Merging when the combined result passes. Use Cancel merge to stop waiting."
659 }
c717d4eb
Keenan Nemetz
13d ago
660 w.render(rw, "request", requestPageView{
661 requestSidebarView: sidebar,
662 Tab: "conversation",
663 Description: w.renderMarkdown(m.Name, req.Description),
664 CommentTarget: commentTargetFromQuery(r),
ed03b3d1
Keenan Nemetz
6h ago
665 LineCommentURL: "/" + m.Name + "/changes/" + req.ID,
c717d4eb
Keenan Nemetz
13d ago
666 Thread: w.requestThreadFrom(r, m, req, commentTargetFromQuery(r), "", comments),
097b2110
Keenan Nemetz
13d ago
667 }, status)
c717d4eb
Keenan Nemetz
13d ago
668 }
669
f64c77e4
Keenan Nemetz
9d ago
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.
675 type 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.
2841d922
Keenan Nemetz
13d ago
684 //
f64c77e4
Keenan Nemetz
9d ago
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.
690 func (w *Web) reviewDiff(m *repo.Meta, req *metadata.Request) reviewChange {
691 change := reviewChange{}
c717d4eb
Keenan Nemetz
13d ago
692 if req.Head == "" {
f64c77e4
Keenan Nemetz
9d ago
693 return change
c717d4eb
Keenan Nemetz
13d ago
694 }
f64c77e4
Keenan Nemetz
9d ago
695 change.From = req.Base + "..." + req.Head
2841d922
Keenan Nemetz
13d ago
696 landed := req.State == "merged"
697 if !landed {
698 landed, _ = m.Git().IsAncestor(req.Head, req.Base)
c717d4eb
Keenan Nemetz
13d ago
699 }
2841d922
Keenan Nemetz
13d ago
700 if landed {
f64c77e4
Keenan Nemetz
9d ago
701 change.Merged = true
702 change.From, change.To = mergedDiffRange(m.Git(), req)
2841d922
Keenan Nemetz
13d ago
703 }
f64c77e4
Keenan Nemetz
9d ago
704 if change.From == "" {
705 return change
2841d922
Keenan Nemetz
13d ago
706 }
f64c77e4
Keenan Nemetz
9d ago
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
c717d4eb
Keenan Nemetz
13d ago
717 }
718
2841d922
Keenan Nemetz
13d ago
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.
725 func 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
c717d4eb
Keenan Nemetz
13d ago
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.
745 func (w *Web) reviewPathFor(m *repo.Meta, req *metadata.Request, path string) string {
f64c77e4
Keenan Nemetz
9d ago
746 for _, f := range w.reviewDiff(m, req).Files {
c717d4eb
Keenan Nemetz
13d ago
747 if f.NewPath == path || f.OldPath == path {
628d4523
Keenan Nemetz
11d ago
748 return diffFilePath(f)
c717d4eb
Keenan Nemetz
13d ago
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.
757 func 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 }