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