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