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