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