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 p.Opened.Name = p.Author
480 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
481 if !m.CreatedAt.IsZero() {
482 p.Opened.Abs = absolute(m.CreatedAt)
483 }
484 p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
485 for _, b := range d.branches {
486 switch b.ShortName {
487 case m.Source:
488 p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
489 case m.Target:
490 p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
491 }
492 }
493 if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
494 p.MergedShort = short(m.MergedCommitID.String())
495 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
496 p.MergeMode = modeName(m.MergeMode)
497 }
498 for _, mode := range d.repo.Settings.AllowedMergeModes {
499 if name := modeName(mode); name != "" {
500 p.Modes = append(p.Modes, name)
501 }
502 }
503 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
504 if head != "" {
505 checks := s.checksFor(ctx, d.repo.Name, head)
506 p.Checks = checksSection(d.repo.Repo, checks)
507 p.ChecksSum = checksSummary(d.repo.Name, head, checks)
508 if !open {
509 p.Checks.Note = ""
510 }
511 }
512 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
513 countNotes(p.Files, res.Comments)
514 if !res.DiffBaseID.IsZero() {
515 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
516 }
517 p.From = from
518
519 // The commits of the request, newest first.
520 for _, c := range res.Commits {
521 row := commitRow(d.name(), c, now)
522 p.Commits = append(p.Commits, MRCommit{
523 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
524 })
525 }
526 lastRead := res.LastReadID.String()
527 if signed && lastRead != "" {
528 // The commits are newest first: those before the last read head are new.
529 // A head that is not among them (the branch was rewritten) marks none.
530 for i, c := range res.Commits {
531 if c.ID.String() != lastRead {
532 continue
533 }
534 for j := 0; j < i; j++ {
535 p.Commits[j].New = true
536 }
537 }
538 }
539 if res.CommitsTruncated {
540 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
541 }
542
543 // Since the last read: the navigation, and what the page says about it.
544 if !signed {
545 lastRead = "" // the read position is the owner's
546 }
547 if signed && lastRead != "" {
548 p.Since = &SinceNav{
549 AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
550 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
551 }
552 }
553 if since {
554 switch {
555 case lastRead == "":
556 p.SinceNote = "nothing was marked read yet: this is the whole change."
557 case res.LastReadLost:
558 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
559 case len(p.Files) == 0:
560 p.SinceNote = "nothing changed since " + short(lastRead) + "."
561 default:
562 p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
563 }
564 }
565 if p.Since != nil {
566 p.Since.Files = len(p.Files)
567 }
568
569 // Comments: under their line, else in the list below the diff. The forms
570 // are the owner's, on the whole diff of an open request.
571 canWrite := signed && open && !since
572 var on *lineRef
573 if canWrite {
574 on = parseOn(r.URL.Query().Get("on"))
575 }
576 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
577 lines, invisible := diffLines(p.Files)
578 p.Invisible = invisible
579 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
580 if signed && open {
581 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
582 if m.IsDraft() {
583 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
584 }
585 }
586 if canWrite {
587 p.GeneralForm = &CommentForm{
588 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
589 }
590 }
591
592 l := s.repoLayout(ctx, d, "merge-requests")
593 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
594 if head != "" {
595 pinRef(&l, target{rev: head, name: m.Source})
596 if !d.hasBranch(m.Source) {
597 pinRef(&l, target{rev: head, name: short(head), detached: true})
598 }
599 }
600 l.Crumbs[0].Current = false
601 l.Crumbs = append(l.Crumbs,
602 Crumb{Label: "merge requests", Href: mrBase(d.name())},
603 Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
604 )
605 if since {
606 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
607 }
608 p.Live = true
609 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))
610 l.HasRail = true
611 l.MR = true
612 return MergeRequest(l, p), http.StatusOK, nil
613}
614
615// hasBranch tells whether the repository has the branch.
616func (d *repoData) hasBranch(name string) bool {
617 for _, b := range d.branches {
618 if b.ShortName == name {
619 return true
620 }
621 }
622 return false
623}
624
625// mergeBox is what the owner can do with an open request.
626func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
627 b := &MergeBox{
628 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
629 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
630 CanMarkRead: head != "" && head != lastRead,
631 CanDeleteBranch: source != d.repo.DefaultBranch,
632 }
633 ff, sq := false, false
634 for _, mode := range d.repo.Settings.AllowedMergeModes {
635 switch mode {
636 case domain.MergeModeFastForward:
637 ff = true
638 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
639 case domain.MergeModeSquash:
640 sq = true
641 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
642 }
643 }
644 if head != "" {
645 cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
646 b.CLI = Clone{ID: "cli-merge"}
647 if ff {
648 b.CLI.Lines = append(b.CLI.Lines, cmd)
649 }
650 if sq {
651 b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
652 }
653 }
654 return b
655}
656
657// shellWord quotes s for a shell unless it is made of safe characters, which
658// a repository name always is.
659func shellWord(s string) string {
660 for _, c := range []byte(s) {
661 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
662 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
663 }
664 }
665 return s
666}
667
668// lineRef designates a line of the diff in the query of the comment form:
669// "?on=new:12:path/to/file".
670type lineRef struct {
671 side string
672 line int
673 path string
674}
675
676func parseOn(s string) *lineRef {
677 side, rest, ok := strings.Cut(s, ":")
678 if !ok || (side != "new" && side != "old") {
679 return nil
680 }
681 num, path, ok := strings.Cut(rest, ":")
682 line, err := strconv.Atoi(num)
683 if !ok || err != nil || line < 1 || path == "" {
684 return nil
685 }
686 return &lineRef{side: side, line: line, path: path}
687}
688
689// placeComments puts every comment under its line when the diff shown has it,
690// else in the list below the diff. The comments name lines of the diff against
691// the target, so a page that shows another diff (since the last read) lists
692// them all below, with their line. A reader who can write gets the line numbers
693// as links to the form of their line; on designates the line whose form shows.
694//
695//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
696func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
697 now := s.cfg.Now()
698 self := mrHref(repo, n)
699 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
700
701 type lineKey struct {
702 path string
703 side domain.DiffSide
704 line int
705 }
706 index := map[lineKey]*DiffLine{}
707 fileIDs := map[string]string{}
708 fileAt := map[string]int{}
709 for fi := range p.Files {
710 f := &p.Files[fi]
711 fileIDs[f.Key] = f.ID
712 fileAt[f.Key] = fi
713 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
714 // The form of a line shows on the page of the window of its file.
715 on := self + "?on="
716 if w := windowStart(fi); w > 0 {
717 on = self + "?from=" + itoa(w) + "&on="
718 }
719 for hi := range f.Hunks {
720 for li := range f.Hunks[hi].Lines {
721 ln := &f.Hunks[hi].Lines[li]
722 if ln.oldN > 0 && ln.Kind != "add" {
723 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
724 if linkable {
725 ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
726 }
727 }
728 if ln.newN > 0 && ln.Kind != "del" {
729 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
730 if linkable {
731 ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
732 }
733 }
734 }
735 }
736 }
737 find := func(path string, side domain.DiffSide, line int) *DiffLine {
738 if !inline {
739 return nil
740 }
741 return index[lineKey{path, side, line}]
742 }
743
744 for _, c := range comments {
745 cid := strconv.FormatInt(c.ID, 10)
746 v := CommentView{
747 ID: cid, Author: s.who(c.Author),
748 Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
749 Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
750 }
751 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
752 if !c.CreatedAt.IsZero() {
753 v.Abs = absolute(c.CreatedAt)
754 }
755 p.NComments++
756 if !c.Resolved {
757 p.NUnresolved++
758 }
759 general := c.Path == ""
760 fi, listed := fileAt[c.Path]
761 if !general && listed && p.Files[fi].Lazy {
762 // Its file comes with its window, and the comment with it.
763 continue
764 }
765 var ln *DiffLine
766 if !general {
767 ln = find(c.Path, c.Side, c.Line)
768 if ln == nil {
769 side := "new"
770 if c.Side == domain.SideOld {
771 side = "old"
772 }
773 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
774 }
775 }
776 if canWrite {
777 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
778 if !general && listed {
779 v.Resolve.From = windowStart(fi)
780 }
781 if c.Resolved {
782 v.Resolve.Label = "Unresolve"
783 }
784 }
785 switch {
786 case ln != nil:
787 ln.Notes = append(ln.Notes, v)
788 case general:
789 p.General = append(p.General, v)
790 case p.LooseInFiles && listed:
791 p.Files[fi].Loose = append(p.Files[fi].Loose, v)
792 default:
793 p.Loose = append(p.Loose, v)
794 }
795 }
796
797 if on != nil {
798 side := domain.SideNew
799 if on.side == "old" {
800 side = domain.SideOld
801 }
802 if ln := find(on.path, side, on.line); ln != nil {
803 ln.Form = &CommentForm{
804 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
805 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
806 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
807 From: windowStart(fileAt[on.path]),
808 }
809 }
810 }
811}
812
813// userText renders the parts of a plain text: the text escaped, the http(s)
814// addresses as links. Its white space is kept by the style of the container.
815func userText(parts []TextPart) templ.Component {
816 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
817 var b strings.Builder
818 for _, part := range parts {
819 if part.Href != "" {
820 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
821 } else {
822 b.WriteString(html.EscapeString(part.Text))
823 }
824 }
825 _, err := io.WriteString(w, b.String())
826 return err
827 })
828}