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