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