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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d ago · parent 4506a4d3

Reorganise the web package around one implementation per rule

server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.

Files now say what they answer:

  web.go          the Web type, render, open, the error pages
  page.go         Page, pageSection, who, nav
  templatefuncs.go the template function map, with the escaping rules noted
  ui.go           relative time, avatars, breadcrumbs, the pager
  markdown.go     the goldmark and bluemonday boundary
  codebrowser.go  the code browser, out of web.go
  tokens.go       the label/assignee token input
  rolepicker.go   the role control, out of admin.go
  comments.go     the comment view, thread builder, action menus, snippet
  comment_writes.go  add, edit and delete a comment, for both surfaces
  write_context.go   beginWrite/beginPost/beginAdminPost, fail, refuse
  capabilities.go    the ACL rules a page and a handler share
  audit.go        the audit log
  request_view.go the change conversation page, out of requests.go
  admin_access.go the instance-admin access rules, out of settings.go

A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.

Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.

Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.

Behaviour changes found while unifying:

  - A reply cycle (two comments answering each other, possible through a pushed
    metadata branch) used to drop both comments from the page; anything the walk
    does not reach is now emitted as a root.
  - A rejected anchored comment keeps its "Commenting on path:line" note and
    hidden anchor fields, so the reviewer does not have to pick the line again.
  - A repository whose access rules cannot be read answers 500 rather than 403;
    the account was being blamed for a broken instance.
  - Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
    everywhere, and the instance feed's page links keep their kind filter.
  - The activity feed, issue list and change list share one page control.

Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.

server/web/requests.go +24−569

@@ -1,15 +1,20 @@
1 1 package web
2 2
3 // Change requests: the list, the filters, and the form that opens one. The
4 // conversation page is request_view.go and the Files tab is request_files.go;
5 // this file is the surface a reader arrives at.
6 //
7 // A change request is read straight from git rather than through the index. The
8 // volume is small and the branch is authoritative, so there is no second copy to
9 // go stale.
10
3 11 import (
4 "fmt"
5 "html/template"
6 12 "net/http"
7 13 "net/url"
8 14 "sort"
9 15 "strconv"
10 16 "strings"
11 17
12 "github.com/rodent-software/vole/core/git"
13 18 "github.com/rodent-software/vole/forge/access"
14 19 "github.com/rodent-software/vole/forge/repo"
15 20 "github.com/rodent-software/vole/metadata"
@@ -117,12 +122,11 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
117 122 return rows[i].ID > rows[j].ID
118 123 })
119 124 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
120 pg := newPager(len(rows), 25, page, func(n int) string {
125 pg := newPager(len(rows), listPageSize, page, func(n int) string {
121 126 return requestListURL(m.Name, state, query, n)
122 })
123 if pg.Total > 0 {
124 rows = rows[(pg.Page-1)*25 : pg.To]
125 }
127 }).forSwap("Change request pages", "#change-list")
128 from, to := pg.rows()
129 rows = rows[from:to]
126 130 form := w.requestForm(r, m)
127 131
128 132 acl, _ := m.ACL()
@@ -205,207 +209,9 @@ func (w *Web) NewChangePage(rw http.ResponseWriter, r *http.Request) {
205 209 }, 0)
206 210 }
207 211
208 // RequestView renders one change request: what it would land, what changed, and
209 // which approvals currently apply.
210 type requestApproval struct {
211 metadata.Approval
212 // Name is the approver's account name; the file itself is keyed by that name.
213 Name string
214 // Current is false when the approval names a commit or base that is no longer
215 // what the request asks for, which is the normal state after a force-push.
216 Current bool
217 }
218
219 type requestCheck struct {
220 ID, Workflow, Job, Status string
221 Exit int
222 }
223
224 // requestCI is the approval a contributor branch needs before its workflow may
225 // run. It belongs on the request because that is where a maintainer reviews the
226 // code the tip contains.
227 type requestCI struct {
228 Ref string
229 CanAllow bool
230 }
231
232 type commentSnippetLine struct {
233 No int
234 Text string
235 Anchor bool
236 }
237
238 // commentSnippet is a few lines around an anchored review comment. It comes
239 // from the blob the comment names, not the current file, so a stale comment
240 // still shows the code the reviewer actually saw.
241 type commentSnippet struct {
242 Path string
243 Line int
244 Side string
245 Lines []commentSnippetLine
246 FilesURL string
247 }
248
249 type requestCommentView struct {
250 metadata.Comment
251 Body template.HTML
252 // Current reports whether the file still holds the content this comment was
253 // written about. A stale comment keeps its place in the thread and loses its
254 // marker on the diff.
255 Current bool
256 Snippet *commentSnippet
257 Replies []requestCommentView
258 // ReplyToName names the account this comment answers. ReplyToDeleted marks a
259 // parent that is a tombstone, so the UI can say so instead of showing no one.
260 ReplyToName string
261 ReplyToDeleted bool
262 CanReply bool
263 CanEdit bool
264 CanDelete bool
265 Source string
266 CSRF string
267 EditURL string
268 DeleteURL string
269 }
270
271 // requestSidebarView is the data the change sidebar fragments need. One type for
272 // the full page and the HTMX replacements, so an approval or state change can
273 // refresh the whole sidebar without rebuilding the rest of the review.
274 type requestSidebarView struct {
275 Page
276 Name string
277 Request *metadata.Request
278 Approvals []requestApproval
279 Checks []requestCheck
280 Required int
281 Current int
282 CanApprove bool
283 CanClose bool
284 CanDiscuss bool
285 ContributorCI *requestCI
286 Error string
287 }
288
289 type commentTarget struct {
290 Path string
291 Line int
292 Side string
293 }
294
295 // requestInlineCommentView is the row-shaped composer inserted under a diff
296 // line. It carries the anchor in hidden fields so the posted comment lands on
297 // the same line the reviewer clicked.
298 type requestInlineCommentView struct {
299 Page
300 Name string
301 Request *metadata.Request
302 Target *commentTarget
303 }
304
305 type requestPageView struct {
306 requestSidebarView
307 Tab string
308 Description template.HTML
309 CommentTarget *commentTarget
310 LineCommentURL string
311 FileCount int
312 Added int
313 Removed int
314 AlreadyMerged bool
315 Comments []requestCommentView
316 Conflicts []string
317 MergeChecked bool
318 ThreadError string
319 }
320
321 func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, title, errMsg string) requestSidebarView {
322 acl, _ := m.ACL()
323 approvals := make([]requestApproval, 0, len(req.Approvals))
324 current := 0
325 for _, a := range req.Approvals {
326 c := a.Head == req.Head && a.Base == req.Base
327 if c {
328 current++
329 }
330 approvals = append(approvals, requestApproval{Approval: a, Name: w.who(a.Principal), Current: c})
331 }
332 required := 0
333 if acl != nil {
334 required = acl.RequiredApprovals()
335 }
336 var checks []requestCheck
337 if w.Runs != nil && req.Head != "" {
338 if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil {
339 for _, run := range res {
340 checks = append(checks, requestCheck{
341 ID: run.ID, Workflow: run.Workflow, Job: run.Job,
342 Status: run.Status, Exit: run.ExitCode,
343 })
344 }
345 sort.Slice(checks, func(i, j int) bool {
346 if checks[i].Workflow != checks[j].Workflow {
347 return checks[i].Workflow < checks[j].Workflow
348 }
349 return checks[i].Job < checks[j].Job
350 })
351 }
352 }
353 canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.Author)
354 canClose := false
355 if pr := w.principal(r); pr != nil && acl != nil {
356 canClose = acl.CanTriage(pr.account.Name) || req.Author == pr.account.Name
357 }
358 var contributorCI *requestCI
359 if req.Open() {
360 if ref := w.pendingContributorCI(m, req.Head); ref != "" {
361 canAllow := false
362 if id := w.accountName(r); id != "" && acl != nil && acl.CanApprove(id) {
363 canAllow = true
364 }
365 contributorCI = &requestCI{Ref: strings.TrimPrefix(ref, "refs/heads/"), CanAllow: canAllow}
366 }
367 }
368 return requestSidebarView{
369 Page: w.page(r, title), Name: m.Name, Request: req,
370 Approvals: approvals, Checks: checks, Required: required, Current: current,
371 CanApprove: canApprove, CanClose: canClose,
372 CanDiscuss: acl != nil && w.canDiscuss(r, acl),
373 ContributorCI: contributorCI,
374 Error: errMsg,
375 }
376 }
377
378 // pendingContributorCI finds the contributor branch whose tip is head and whose
379 // CI still waits for a maintainer. It is keyed by the commit: a branch that has
380 // moved past this request is not what is under review here.
381 func (w *Web) pendingContributorCI(m *repo.Meta, head string) string {
382 if w.Runs == nil || head == "" {
383 return ""
384 }
385 refs, err := m.Git().ListRefs("refs/heads/")
386 if err != nil {
387 return ""
388 }
389 var candidates []string
390 for ref, tip := range refs {
391 if tip != head {
392 continue
393 }
394 if _, ok := access.ContributorRefName(ref); ok {
395 candidates = append(candidates, ref)
396 }
397 }
398 sort.Strings(candidates)
399 for _, ref := range candidates {
400 allowed, err := w.Runs.CIAllowed(m.Name, ref, head)
401 if err == nil && !allowed {
402 return ref
403 }
404 }
405 return ""
406 }
407
408 func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestCommentView {
212 // requestComments renders the change's discussion, marking each anchored comment
213 // with whether the line it names is still what the reviewer saw.
214 func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []commentView {
409 215 head := req.Head
410 216 baseTip := ""
411 217 if req.Base != "" {
@@ -437,369 +243,18 @@ func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestComm
437 243 return blob
438 244 }
439 245 snippetBlobs := map[string][]string{}
440 comments := make([]requestCommentView, 0, len(req.Comments))
441 for _, cm := range req.Comments {
442 rc := requestCommentView{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body), Source: cm.Body}
443 if cm.Anchor != nil && !cm.Deleted {
444 rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob
445 rc.Snippet = buildCommentSnippet(m.Git(), cm.Anchor, snippetBlobs)
446 if rc.Snippet != nil {
447 rc.Snippet.FilesURL = "/r/" + m.Name + "/requests/" + req.ID +
448 "/files/" + pathURL(rc.Snippet.Path) + "#inline-c-" + cm.ID
449 }
450 }
451 comments = append(comments, rc)
452 }
453 return threadRequestComments(comments)
454 }
455
456 // threadRequestComments puts every descendant under its top-level root. Replies
457 // may answer any comment, but the page shows one level; a missing parent becomes
458 // a tombstone so replies to a deleted comment stay in their thread.
459 func threadRequestComments(comments []requestCommentView) []requestCommentView {
460 nodes := make(map[string]requestCommentView, len(comments))
461 for _, c := range comments {
462 nodes[c.ID] = c
463 }
464 for _, c := range comments {
465 if c.ReplyTo != "" {
466 if _, ok := nodes[c.ReplyTo]; !ok {
467 nodes[c.ReplyTo] = requestCommentView{Comment: metadata.Comment{ID: c.ReplyTo, Deleted: true}}
468 }
469 }
470 }
471 ids := make([]string, 0, len(nodes))
472 for id := range nodes {
473 ids = append(ids, id)
474 }
475 sort.Strings(ids)
476 children := make(map[string][]string, len(nodes))
477 var roots []string
478 for _, id := range ids {
479 c := nodes[id]
480 if c.ReplyTo == "" || c.ReplyTo == id {
481 roots = append(roots, id)
482 continue
483 }
484 if _, ok := nodes[c.ReplyTo]; ok {
485 children[c.ReplyTo] = append(children[c.ReplyTo], id)
486 } else {
487 roots = append(roots, id)
488 }
489 }
490 out := make([]requestCommentView, 0, len(roots))
491 for _, id := range roots {
492 root := nodes[id]
493 seen := map[string]bool{id: true}
494 var replies []string
495 var walk func(string)
496 walk = func(parent string) {
497 for _, child := range children[parent] {
498 if seen[child] {
499 continue
500 }
501 seen[child] = true
502 replies = append(replies, child)
503 walk(child)
504 }
505 }
506 walk(id)
507 sort.Strings(replies)
508 for _, rid := range replies {
509 reply := nodes[rid]
510 if parent, ok := nodes[reply.ReplyTo]; ok {
511 reply.ReplyToName = parent.Author
512 reply.ReplyToDeleted = parent.Deleted
513 }
514 root.Replies = append(root.Replies, reply)
515 }
516 out = append(out, root)
517 }
518 return out
519 }
520
521 // commentSnippet reads the anchored blob and returns the lines around the
522 // comment, with the anchored line marked. The blob cache is per page, so many
523 // comments on one file cost one cat-file.
524 func buildCommentSnippet(g *git.Repo, a *metadata.Anchor, cache map[string][]string) *commentSnippet {
525 const contextLines = 3
526 if a == nil || a.Blob == "" || a.Line <= 0 {
527 return nil
528 }
529 lines, ok := cache[a.Blob]
530 if !ok {
531 data, err := g.BlobBySHA(a.Blob)
532 if err != nil || len(data) > 1<<20 {
533 cache[a.Blob] = nil
534 return nil
535 }
536 text := string(data)
537 if strings.IndexByte(text, 0) >= 0 {
538 cache[a.Blob] = nil
539 return nil
540 }
541 lines = strings.Split(text, "\n")
542 if len(lines) > 0 && lines[len(lines)-1] == "" {
543 lines = lines[:len(lines)-1]
544 }
545 cache[a.Blob] = lines
546 }
547 if a.Line > len(lines) {
548 return nil
549 }
550 start := a.Line - contextLines
551 if start < 1 {
552 start = 1
553 }
554 end := a.Line + contextLines
555 if end > len(lines) {
556 end = len(lines)
557 }
558 out := &commentSnippet{Path: a.Path, Line: a.Line, Side: a.Side}
559 for no := start; no <= end; no++ {
560 out.Lines = append(out.Lines, commentSnippetLine{
561 No: no, Text: lines[no-1], Anchor: no == a.Line,
562 })
563 }
564 return out
565 }
566
567 // requestDiscussionView is the comment thread fragment, separate from the
568 // sidebar so posting a comment swaps only the conversation.
569 type requestDiscussionView struct {
570 Page
571 Name string
572 Request *metadata.Request
573 Comments []requestCommentView
574 CanDiscuss bool
575 CommentTarget *commentTarget
576 ThreadError string
577 }
578
579 func (w *Web) requestDiscussion(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) requestDiscussionView {
580 acl, _ := m.ACL()
581 canDiscuss := acl != nil && w.canDiscuss(r, acl)
582 comments := w.requestComments(m, req)
583 w.markRequestCommentActions(r, m, req.ID, comments, canDiscuss)
584 return requestDiscussionView{
585 Page: w.page(r, ""), Name: m.Name, Request: req,
586 Comments: comments, CommentTarget: target,
587 CanDiscuss: canDiscuss, ThreadError: errMsg,
588 }
589 }
590
591 func (w *Web) markRequestCommentActions(r *http.Request, m *repo.Meta, requestID string, comments []requestCommentView, canDiscuss bool) {
592 pr := w.principal(r)
593 acl, _ := m.ACL()
594 admin := pr != nil && acl != nil && acl.RoleFor(pr.account.Name).AtLeast(access.Admin)
595 csrf := ""
596 if pr != nil {
597 csrf = pr.session.CSRF
598 }
246 comments := w.commentViews(m, req.Comments)
599 247 for i := range comments {
600 248 c := &comments[i]
601 c.CSRF = csrf
602 c.CanReply = canDiscuss && !c.Deleted
603 c.CanEdit = !c.Deleted && pr != nil && (c.Author == pr.account.Name || admin)
604 c.CanDelete = c.CanEdit
605 if c.CanEdit {
606 base := "/r/" + m.Name + "/requests/" + requestID + "/comments/" + c.ID
607 c.EditURL = base + "/edit"
608 c.DeleteURL = base + "/delete"
609 }
610 w.markRequestCommentActions(r, m, requestID, c.Replies, canDiscuss)
611 }
612 }
613
614 func commentTargetFromQuery(r *http.Request) *commentTarget {
615 q := r.URL.Query()
616 path := strings.TrimSpace(q.Get("path"))
617 side := strings.TrimSpace(q.Get("side"))
618 if side == "" {
619 side = "new"
620 }
621 line, _ := strconv.Atoi(q.Get("line"))
622 if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) {
623 return nil
624 }
625 return &commentTarget{Path: path, Line: line, Side: side}
626 }
627
628 // RequestCommentForm returns the inline composer for one diff line. The link in
629 // the diff is a normal request fallback; with HTMX the row is inserted directly
630 // beneath the line instead of sending the reviewer to the bottom of the page.
631 func (w *Web) RequestCommentForm(rw http.ResponseWriter, r *http.Request) {
632 pr, ok := w.requireHTMLUser(rw, r)
633 if !ok {
634 return
635 }
636 m, ok := w.open(rw, r)
637 if !ok {
638 return
639 }
640 acl, err := m.ACL()
641 if err != nil || !acl.CanDiscuss(pr.account.Name) {
642 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
643 return
644 }
645 req, err := metadata.GetRequest(m.Git(), r.PathValue("id"))
646 if err != nil {
647 w.notFound(rw, r)
648 return
649 }
650 target := commentTargetFromQuery(r)
651 if target == nil {
652 http.Error(rw, "choose a line to comment on", http.StatusBadRequest)
653 return
654 }
655 if _, err := w.commentAnchor(m, req, target.Path, target.Line, target.Side); err != nil {
656 http.Error(rw, err.Error(), http.StatusBadRequest)
657 return
658 }
659 w.render(rw, "request_inline_comment", requestInlineCommentView{
660 Page: w.page(r, ""), Name: m.Name, Request: req, Target: target,
661 }, 0)
662 }
663
664 // commentAnchor resolves a line anchor against the content the reviewer saw.
665 // The blob is what later answers "has this comment gone stale?"; storing only a
666 // path and line would make the comment slide to whatever now occupies that line.
667 func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) {
668 if !safePath(path) {
669 return nil, fmt.Errorf("invalid anchor path")
670 }
671 if line <= 0 {
672 return nil, fmt.Errorf("invalid anchor line")
673 }
674 commit := req.Head
675 if side == "old" {
676 baseTip, err := m.Git().RevParse("refs/heads/" + req.Base)
677 if err != nil || baseTip == "" {
678 return nil, fmt.Errorf("the base branch is not available")
679 }
680 mergeBase, err := m.Git().MergeBase(baseTip, req.Head)
681 if err != nil || mergeBase == "" {
682 return nil, fmt.Errorf("cannot find the merge base for this change")
683 }
684 commit = mergeBase
685 } else if side != "new" {
686 return nil, fmt.Errorf("anchor side must be new or old")
687 }
688 if commit == "" {
689 return nil, fmt.Errorf("this change has no head commit to comment on")
690 }
691 blob, err := m.Git().RevParseAny(commit + ":" + path)
692 if err != nil || blob == "" {
693 return nil, fmt.Errorf("that file is not part of this change")
694 }
695 return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil
696 }
697
698 func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
699 m, ok := w.open(rw, r)
700 if !ok {
701 return
702 }
703 req, err := metadata.GetRequest(m.Git(), r.PathValue("id"))
704 if err != nil {
705 w.notFound(rw, r)
706 return
707 }
708
709 files, _, alreadyMerged := w.requestDiff(m, req)
710 comments := w.requestComments(m, req)
711 attachRequestComments(files, comments)
712 added, removed := diffSummary(files)
713
714 // Would it land? A diff says what changed; it cannot say whether the branch
715 // still merges. merge-tree answers that in memory, so the reviewer sees "this
716 // needs a rebase" before anybody approves — and the answer is only claimed
717 // when the question could actually be asked (base branch present, head
718 // reachable), because a page that says "clean" when it did not check is worse
719 // than one that says nothing.
720 var conflicts []string
721 mergeChecked := false
722 if req.Open() && req.Head != "" {
723 if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil && tip != "" {
724 if paths, err := m.Git().MergeConflicts(tip, req.Head); err == nil {
725 conflicts, mergeChecked = paths, true
726 }
727 }
728 }
729
730 sidebar := w.requestSidebar(r, m, req, m.Name+" · "+req.Title, "")
731 w.markRequestCommentActions(r, m, req.ID, comments, sidebar.CanDiscuss)
732 w.render(rw, "request", requestPageView{
733 requestSidebarView: sidebar,
734 Tab: "conversation",
735 Description: w.renderMarkdown(m.Name, req.Description),
736 CommentTarget: commentTargetFromQuery(r),
737 LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID,
738 FileCount: len(files),
739 Added: added,
740 Removed: removed,
741 AlreadyMerged: alreadyMerged,
742 Comments: comments,
743 Conflicts: conflicts,
744 MergeChecked: mergeChecked,
745 }, 0)
746 }
747
748 // requestDiff computes the three-dot patch for a change and answers whether it
749 // is already merged. The diff is cheap for one branch and always matches what
750 // the approver is being asked to approve.
751 func (w *Web) requestDiff(m *repo.Meta, req *metadata.Request) (files []diffFile, truncated, alreadyMerged bool) {
752 if req.Head == "" {
753 return nil, false, false
754 }
755 if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil {
756 files = parseDiff(patch)
757 truncated = cut
758 }
759 // An empty diff means one of two very different things, so say which.
760 if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in {
761 alreadyMerged = true
762 }
763 return files, truncated, alreadyMerged
764 }
765
766 // reviewPathFor maps an anchor's path to the file row a review URL should open.
767 // A comment on the old side of a rename names the old path, while the Files tree
768 // keys the change by its new path.
769 func (w *Web) reviewPathFor(m *repo.Meta, req *metadata.Request, path string) string {
770 files, _, _ := w.requestDiff(m, req)
771 for _, f := range files {
772 if f.NewPath == path || f.OldPath == path {
773 return reviewFilePath(f)
249 if c.Anchor == nil || c.Deleted {
250 continue
774 251 }
775 }
776 return path
777 }
778
779 // attachRequestComments puts each current anchored comment beneath the line it
780 // names. A comment on a deleted line belongs to the file as it stood at the
781 // merge base; stale comments stay in the discussion and lose their line note.
782 func attachRequestComments(files []diffFile, comments []requestCommentView) {
783 for i := range files {
784 f := &files[i]
785 for h := range f.Hunks {
786 lines := f.Hunks[h].Lines
787 for l := range lines {
788 for _, rc := range comments {
789 if rc.Anchor == nil || rc.Deleted || !rc.Current {
790 continue
791 }
792 path, no := f.NewPath, lines[l].NewNo
793 if rc.Anchor.Side == "old" {
794 path, no = f.OldPath, lines[l].OldNo
795 }
796 if path == rc.Anchor.Path && no == rc.Anchor.Line {
797 lines[l].Comments = append(lines[l].Comments, diffComment{
798 ID: rc.ID, Author: rc.Author, Created: rc.Created, Body: rc.Body,
799 })
800 }
801 }
802 }
252 c.Current = currentBlob(c.Anchor.Side, c.Anchor.Path) == c.Anchor.Blob
253 c.Snippet = buildCommentSnippet(m.Git(), c.Anchor, snippetBlobs)
254 if c.Snippet != nil {
255 c.Snippet.FilesURL = "/r/" + m.Name + "/requests/" + req.ID +
256 "/files/" + pathURL(c.Snippet.Path) + "#inline-c-" + c.ID
803 257 }
804 258 }
259 return threadComments(comments)
805 260 }