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