1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "html"
8 "io"
9 "net/http"
10 "net/url"
11 "strconv"
12 "strings"
13
14 "connectrpc.com/connect"
15 "github.com/a-h/templ"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18)
19
20// The merge request pages (ADR 0017). They read through MergeRequestService
21// and GitService as the visitor of the request: anonymous sees the merge
22// requests of public repositories, the signed-in owner those of every
23// repository, and only the owner gets the forms (mr_post.go handles them).
24//
25// Routes:
26//
27// GET /<repo>/merge-requests the list, open ones first
28// GET /<repo>/merge-requests/<n> the request with its diff
29// GET /<repo>/merge-requests/<n>/since the diff since the last read
30// GET /<repo>/compare/<branch> a branch against the default branch
31//
32// Reading marks nothing as read. MarkRead stores state, and a GET must stay
33// safe: a prefetcher, a link preview or a reload of the owner's tab would move
34// the read position, and the "since your last read" diff would lose what the
35// owner never saw. So the owner marks a head read with a POST button
36// ("[mark read]", /<n>/read), which carries the head the page showed; the
37// button only shows when that head is not the one already read. The since page
38// is the owner's alone (the position is the owner's), and answers 404 to
39// anyone else.
40//
41// Every page here is rendered through page(), so for the owner it is
42// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
43// forms and the CLI lines exist only in the owner's copy.
44
45const (
46 // mrPerPage is how many decided merge requests a page of the list shows,
47 // at least; pages of the API are read whole.
48 mrPerPage = 50
49 // maxMRPages bounds the API pages one list page reads.
50 maxMRPages = 20
51 // maxOpenPages bounds the pages read for the open merge requests, which
52 // the first list page shows in full.
53 maxOpenPages = 5
54 // maxComparePages bounds how many pages of history the compare page reads
55 // to find where the branch left the target.
56 maxComparePages = 5
57 // railBranches is how many branches the list offers to compare.
58 railBranches = 15
59)
60
61// MRRow is one merge request in the list.
62type MRRow struct {
63 Number string
64 Href string
65 Title string
66 Branches string
67 Author string
68 State string
69 When string
70 WhenAt string
71 Cursor bool
72}
73
74// BranchLink is a branch the list offers to open a merge request for.
75type BranchLink struct{ Name, Href string }
76
77// MRListPage is the list of the merge requests of a repository.
78type MRListPage struct {
79 Open, Done []MRRow
80 // OpenNote says the open list was cut.
81 OpenNote string
82 // Older and Newest are the paging links of the decided requests.
83 Older, Newest string
84 Branches []BranchLink
85 Default string
86 CLI Clone
87 // Live is true when the page announces changes.
88 Live bool
89}
90
91// CommentView is a comment of a merge request, ready to show.
92type CommentView struct {
93 ID string
94 Author string
95 When string
96 WhenAt string
97 Abs string
98 Parts []TextPart
99 Resolved bool
100 // Outdated is true when the head moved since the comment was written, so
101 // its line may have moved too.
102 Outdated bool
103 // Where names the line of a comment that is not under a line of the diff
104 // shown ("path:12 (new)").
105 Where string
106 // Resolve is the form that resolves or unresolves it; nil when the reader
107 // cannot.
108 Resolve *ResolveForm
109}
110
111// ResolveForm is the POST that changes the resolved state of a comment.
112type ResolveForm struct {
113 Action string
114 // Value is the state the form sets.
115 Value bool
116 Label string
117}
118
119// CommentForm is the POST that adds a comment.
120type CommentForm struct {
121 ID string
122 Action string
123 Title string
124 // Path, Line and Side designate the line; empty for a general comment.
125 Path, Line, Side string
126 Cancel string
127 Focus bool
128}
129
130// MRCommit is a commit of the request.
131type MRCommit struct {
132 Short, Href, Subject, Author, When, WhenAt string
133 // New is true for a commit pushed after the head the reviewer last read.
134 New bool
135}
136
137// MergeBox is what the owner does with an open request.
138type MergeBox struct {
139 MergeAction string
140 CloseAction string
141 ReadAction string
142 Head string
143 Modes []ModeOption
144 // HeadGone is true when the source branch no longer exists: there is
145 // nothing to merge.
146 HeadGone bool
147 CLI Clone
148 // CanMarkRead is true when the head differs from the one last read.
149 CanMarkRead bool
150 // CanDeleteBranch offers to delete the source after the merge: it is not
151 // the default branch. The server checks again, with the open requests.
152 CanDeleteBranch bool
153}
154
155// ModeOption is a merge mode the repository allows.
156type ModeOption struct{ Value, Label string }
157
158// SinceNav switches between the whole diff and the diff since the last read.
159type SinceNav struct {
160 AllHref, SinceHref string
161 Files int
162 ReadShort string
163 HeadShort string
164 Current bool
165}
166
167// MRPage is a merge request with its diff and comments.
168type MRPage struct {
169 Number string
170 Title string
171 State string
172 Open bool
173 Signed bool
174 Source, Target string
175 SourceHref string
176 TargetHref string
177 Author string
178 Opened Person
179 Updated string
180 UpdatedAt string
181 Desc []TextPart
182 Head string
183 HeadShort string
184 MergedShort string
185 MergedHref string
186 MergeMode string
187
188 Since *SinceNav
189 SinceNote string
190
191 NFiles, Adds, Dels int
192 Files []DiffFile
193 DiffView
194 Note string
195 Invisible bool
196
197 // General are the comments that are not on a line, Loose those on a line
198 // that the diff shown does not have.
199 General, Loose []CommentView
200 NComments int
201 NUnresolved int
202 GeneralForm *CommentForm
203
204 Commits []MRCommit
205 CommitsNote string
206 Modes []string
207 ChecksBlock bool
208
209 Box *MergeBox
210 // Live is true when the page announces changes.
211 Live bool
212}
213
214// CompareCommit is a commit of a branch that the target lacks.
215type CompareCommit = CommitRow
216
217// ComparePage is a branch compared with the default branch.
218type ComparePage struct {
219 Source, Target string
220 SourceHref string
221 TargetHref string
222 Ahead []CommitRow
223 AheadText string
224 CLI Clone
225 // Note says what the page could not tell.
226 Note string
227 // The diff is the one of the single commit the branch is ahead by.
228 NFiles, Adds, Dels int
229 Files []DiffFile
230 DiffView
231 Invisible bool
232 HasDiff bool
233 // Existing is the open merge request for this pair, when there is one.
234 Existing *Link
235 // Create is the form to open one; nil when the reader cannot or there is
236 // one already.
237 Create *CreateForm
238}
239
240// CreateForm is the POST that opens a merge request.
241type CreateForm struct {
242 Action, Source, Target, Title string
243}
244
245// mrBase is the URL of the merge request pages of a repository.
246func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
247
248func mrHref(repo string, n int64, sub ...string) string {
249 return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
250}
251
252// mrNumber reads the number of the path: a positive decimal number, written
253// without sign or leading zeros, else the page is not found.
254func mrNumber(r *http.Request) (int64, error) {
255 s := r.PathValue("n")
256 n, err := strconv.ParseInt(s, 10, 64)
257 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
258 return 0, errNotFound
259 }
260 return n, nil
261}
262
263func stateName(s pb.MergeRequestState) string {
264 switch s {
265 case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
266 return "open"
267 case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
268 return "merged"
269 case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
270 return "closed"
271 }
272 return "unknown"
273}
274
275func modeName(m pb.MergeMode) string {
276 switch m {
277 case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
278 return "fast-forward"
279 case pb.MergeMode_MERGE_MODE_SQUASH:
280 return "squash"
281 }
282 return ""
283}
284
285// mrRow converts a merge request of a listing.
286func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
287 row := MRRow{
288 Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
289 Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
290 Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
291 Author: visibleText(m.GetAuthor()),
292 }
293 ts := m.GetUpdatedAt()
294 if !ts.IsValid() {
295 ts = m.GetCreatedAt()
296 }
297 row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
298 return row
299}
300
301// mrList lists the merge requests: the open ones, then the merged and closed
302// ones, a page at a time. The decided ones are paged with the page token of
303// the API in "page"; the open ones show on the first page only.
304func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
305 d, err := s.loadRepo(ctx, r.PathValue("repo"))
306 if err != nil {
307 return nil, 0, err
308 }
309 token := r.URL.Query().Get("page")
310 p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
311 p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
312 base := mrBase(d.name())
313
314 if token == "" {
315 tok := ""
316 for i := 0; i < maxOpenPages; i++ {
317 res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
318 Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
319 }))
320 if err != nil {
321 return nil, 0, err
322 }
323 for _, m := range res.Msg.GetMergeRequests() {
324 p.Open = append(p.Open, s.mrRow(d.name(), m))
325 }
326 if tok = res.Msg.GetNextPageToken(); tok == "" {
327 break
328 }
329 }
330 if tok != "" {
331 p.OpenNote = "more open merge requests than are listed here."
332 }
333 }
334
335 tok := token
336 for i := 0; i < maxMRPages; i++ {
337 res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
338 Repo: d.name(), PageSize: pageSize, PageToken: tok,
339 }))
340 if err != nil {
341 return nil, 0, err
342 }
343 for _, m := range res.Msg.GetMergeRequests() {
344 if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
345 p.Done = append(p.Done, s.mrRow(d.name(), m))
346 }
347 }
348 tok = res.Msg.GetNextPageToken()
349 if tok == "" || len(p.Done) >= mrPerPage {
350 break
351 }
352 }
353 if tok != "" {
354 p.Older = base + "?page=" + url.QueryEscape(tok)
355 }
356 if token != "" {
357 p.Newest = base
358 }
359 switch {
360 case len(p.Open) > 0:
361 p.Open[0].Cursor = true
362 case len(p.Done) > 0:
363 p.Done[0].Cursor = true
364 }
365 for _, b := range d.branches {
366 if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches {
367 continue
368 }
369 p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())})
370 }
371
372 l := s.repoLayout(ctx, d, "merge-requests")
373 l.Title = "merge requests · " + d.name() + " · " + s.instance
374 l.Crumbs[0].Current = false
375 l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
376 if token == "" {
377 p.Live = true
378 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
379 }
380 l.HasRail = true
381 return MRList(l, p), http.StatusOK, nil
382}
383
384// mrShow shows a merge request: the whole diff against the target.
385func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
386 return s.mrPage(ctx, r, false)
387}
388
389// mrSince shows the diff since the head the reviewer last marked read.
390func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
391 return s.mrPage(ctx, r, true)
392}
393
394func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
395 n, err := mrNumber(r)
396 if err != nil {
397 return nil, 0, err
398 }
399 if since && !SignedIn(ctx) {
400 // The read position is the owner's: there is nothing to show others.
401 return nil, 0, errNotFound
402 }
403 d, err := s.loadRepo(ctx, r.PathValue("repo"))
404 if err != nil {
405 return nil, 0, err
406 }
407 res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
408 Repo: d.name(), Number: n, SinceLastRead: since,
409 }))
410 if err != nil {
411 return nil, 0, err
412 }
413 m := res.Msg.GetMergeRequest()
414 if m == nil {
415 return nil, 0, errNotFound
416 }
417 now := s.cfg.Now()
418 signed := SignedIn(ctx)
419 open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
420 head := m.GetHeadId()
421 self := mrHref(d.name(), n)
422 if since {
423 self = mrHref(d.name(), n, "since")
424 }
425
426 p := MRPage{
427 Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
428 State: stateName(m.GetState()), Open: open, Signed: signed,
429 Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
430 Author: visibleText(m.GetAuthor()),
431 Desc: plainParts(m.GetDescription()),
432 Head: head, HeadShort: short(head),
433 NFiles: int(res.Msg.GetStats().GetFilesChanged()),
434 Adds: int(res.Msg.GetStats().GetAdditions()),
435 Dels: int(res.Msg.GetStats().GetDeletions()),
436 Files: diffFiles(res.Msg.GetDiff().GetFiles()),
437 }
438 p.Opened.Name = p.Author
439 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
440 if t := m.GetCreatedAt(); t.IsValid() {
441 p.Opened.Abs = absolute(t.AsTime())
442 }
443 p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
444 for _, b := range d.branches {
445 switch b.GetShortName() {
446 case m.GetSourceBranch():
447 p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
448 case m.GetTargetBranch():
449 p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
450 }
451 }
452 if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
453 p.MergedShort = short(m.GetMergedCommitId())
454 p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
455 p.MergeMode = modeName(m.GetMergeMode())
456 }
457 if st := d.repo.GetSettings(); st != nil {
458 for _, mode := range st.GetAllowedMergeModes() {
459 if name := modeName(mode); name != "" {
460 p.Modes = append(p.Modes, name)
461 }
462 }
463 p.ChecksBlock = st.GetFailingChecksBlock()
464 }
465 p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
466
467 // The commits of the request, newest first.
468 for _, c := range res.Msg.GetCommits() {
469 row := commitRow(d.name(), c, now)
470 p.Commits = append(p.Commits, MRCommit{
471 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
472 })
473 }
474 lastRead := res.Msg.GetLastReadId()
475 if signed && lastRead != "" {
476 // The commits are newest first: those before the last read head are new.
477 // A head that is not among them (the branch was rewritten) marks none.
478 for i, c := range res.Msg.GetCommits() {
479 if c.GetId() != lastRead {
480 continue
481 }
482 for j := 0; j < i; j++ {
483 p.Commits[j].New = true
484 }
485 }
486 }
487 if res.Msg.GetCommitsTruncated() {
488 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
489 }
490
491 // Since the last read: the navigation, and what the page says about it.
492 if !signed {
493 lastRead = "" // the read position is the owner's
494 }
495 if signed && lastRead != "" {
496 p.Since = &SinceNav{
497 AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
498 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
499 }
500 }
501 if since {
502 switch {
503 case lastRead == "":
504 p.SinceNote = "nothing was marked read yet: this is the whole change."
505 case res.Msg.GetLastReadLost():
506 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
507 case len(p.Files) == 0:
508 p.SinceNote = "nothing changed since " + short(lastRead) + "."
509 default:
510 p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
511 }
512 }
513 if p.Since != nil {
514 p.Since.Files = len(p.Files)
515 }
516
517 // Comments: under their line, else in the list below the diff. The forms
518 // are the owner's, on the whole diff of an open request.
519 canWrite := signed && open && !since
520 var on *lineRef
521 if canWrite {
522 on = parseOn(r.URL.Query().Get("on"))
523 }
524 s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on)
525 lines, invisible := diffLines(p.Files)
526 p.Invisible = invisible
527 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
528 if signed && open {
529 p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead)
530 }
531 if canWrite {
532 p.GeneralForm = &CommentForm{
533 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
534 }
535 }
536
537 l := s.repoLayout(ctx, d, "merge-requests")
538 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
539 if head != "" {
540 pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
541 if !d.hasBranch(m.GetSourceBranch()) {
542 pinRef(&l, target{rev: head, name: short(head), detached: true})
543 }
544 }
545 l.Crumbs[0].Current = false
546 l.Crumbs = append(l.Crumbs,
547 Crumb{Label: "merge requests", Href: mrBase(d.name())},
548 Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
549 )
550 if since {
551 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
552 }
553 p.Live = true
554 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))
555 l.HasRail = true
556 return MergeRequest(l, p), http.StatusOK, nil
557}
558
559// hasBranch tells whether the repository has the branch.
560func (d *repoData) hasBranch(name string) bool {
561 for _, b := range d.branches {
562 if b.GetShortName() == name {
563 return true
564 }
565 }
566 return false
567}
568
569// mergeBox is what the owner can do with an open request.
570func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
571 b := &MergeBox{
572 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
573 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
574 CanMarkRead: head != "" && head != lastRead,
575 CanDeleteBranch: source != d.repo.GetDefaultBranch(),
576 }
577 ff, sq := false, false
578 if st := d.repo.GetSettings(); st != nil {
579 for _, mode := range st.GetAllowedMergeModes() {
580 switch mode {
581 case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
582 ff = true
583 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
584 case pb.MergeMode_MERGE_MODE_SQUASH:
585 sq = true
586 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
587 }
588 }
589 }
590 if head != "" {
591 cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
592 b.CLI = Clone{ID: "cli-merge"}
593 if ff {
594 b.CLI.Lines = append(b.CLI.Lines, cmd)
595 }
596 if sq {
597 b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
598 }
599 }
600 return b
601}
602
603// shellWord quotes s for a shell unless it is made of safe characters, which
604// a repository name always is.
605func shellWord(s string) string {
606 for _, c := range []byte(s) {
607 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
608 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
609 }
610 }
611 return s
612}
613
614// lineRef designates a line of the diff in the query of the comment form:
615// "?on=new:12:path/to/file".
616type lineRef struct {
617 side string
618 line int
619 path string
620}
621
622func parseOn(s string) *lineRef {
623 side, rest, ok := strings.Cut(s, ":")
624 if !ok || (side != "new" && side != "old") {
625 return nil
626 }
627 num, path, ok := strings.Cut(rest, ":")
628 line, err := strconv.Atoi(num)
629 if !ok || err != nil || line < 1 || path == "" {
630 return nil
631 }
632 return &lineRef{side: side, line: line, path: path}
633}
634
635// placeComments puts every comment under its line when the diff shown has it,
636// else in the list below the diff. The comments name lines of the diff against
637// the target, so a page that shows another diff (since the last read) lists
638// them all below, with their line. A reader who can write gets the line numbers
639// as links to the form of their line; on designates the line whose form shows.
640func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
641 now := s.cfg.Now()
642 self := mrHref(repo, n)
643 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
644
645 type lineKey struct {
646 path string
647 side pb.DiffSide
648 line int
649 }
650 index := map[lineKey]*DiffLine{}
651 fileIDs := map[string]string{}
652 for fi := range p.Files {
653 f := &p.Files[fi]
654 fileIDs[f.Key] = f.ID
655 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
656 for hi := range f.Hunks {
657 for li := range f.Hunks[hi].Lines {
658 ln := &f.Hunks[hi].Lines[li]
659 if ln.oldN > 0 && ln.Kind != "add" {
660 index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
661 if linkable {
662 ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
663 }
664 }
665 if ln.newN > 0 && ln.Kind != "del" {
666 index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
667 if linkable {
668 ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
669 }
670 }
671 }
672 }
673 }
674 find := func(path string, side pb.DiffSide, line int) *DiffLine {
675 if !inline {
676 return nil
677 }
678 return index[lineKey{path, side, line}]
679 }
680
681 for _, c := range comments {
682 v := CommentView{
683 ID: c.GetId(), Author: visibleText(c.GetAuthor()),
684 Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
685 Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
686 }
687 v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
688 if t := c.GetCreatedAt(); t.IsValid() {
689 v.Abs = absolute(t.AsTime())
690 }
691 p.NComments++
692 if !c.GetResolved() {
693 p.NUnresolved++
694 }
695 general := c.GetPath() == ""
696 var ln *DiffLine
697 if !general {
698 ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
699 if ln == nil {
700 side := "new"
701 if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
702 side = "old"
703 }
704 v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
705 }
706 }
707 if canWrite {
708 v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"}
709 if c.GetResolved() {
710 v.Resolve.Label = "Unresolve"
711 }
712 }
713 switch {
714 case ln != nil:
715 ln.Notes = append(ln.Notes, v)
716 case general:
717 p.General = append(p.General, v)
718 default:
719 p.Loose = append(p.Loose, v)
720 }
721 }
722
723 if on != nil {
724 side := pb.DiffSide_DIFF_SIDE_NEW
725 if on.side == "old" {
726 side = pb.DiffSide_DIFF_SIDE_OLD
727 }
728 if ln := find(on.path, side, on.line); ln != nil {
729 ln.Form = &CommentForm{
730 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
731 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
732 Cancel: self + "#" + fileIDs[on.path], Focus: true,
733 }
734 }
735 }
736}
737
738// userText renders the parts of a plain text: the text escaped, the http(s)
739// addresses as links. Its white space is kept by the style of the container.
740func userText(parts []TextPart) templ.Component {
741 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
742 var b strings.Builder
743 for _, part := range parts {
744 if part.Href != "" {
745 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
746 } else {
747 b.WriteString(html.EscapeString(part.Text))
748 }
749 }
750 _, err := io.WriteString(w, b.String())
751 return err
752 })
753}