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 if since && !SignedIn(ctx) {
562 return nil, 0, errNotFound
563 }
564 n, err := mrNumber(r)
565 if err != nil {
566 return nil, 0, err
567 }
568 d, err := s.loadRepo(ctx, r.PathValue("repo"))
569 if err != nil {
570 return nil, 0, err
571 }
572 from, err := parseFrom(r)
573 if err != nil {
574 return nil, 0, err
575 }
576 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
577 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
578 Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
579 })
580 if err != nil {
581 return nil, 0, err
582 }
583 if from > 0 && from >= len(res.Diff.Files) {
584 return nil, 0, errNotFound
585 }
586 m := res.MergeRequest
587 signed := SignedIn(ctx)
588 open := m.State == domain.MRStateOpen
589 head := m.HeadID.String()
590 self := mrHref(d.name(), n)
591 if since {
592 self = mrHref(d.name(), n, "since")
593 }
594
595 p := MRPage{
596 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
597 State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
598 Source: visibleText(m.Source), Target: visibleText(m.Target),
599 Author: s.who(m.Author),
600 Head: head, HeadShort: short(head),
601 NFiles: res.Stats.FilesChanged,
602 Adds: res.Stats.Additions,
603 Dels: res.Stats.Deletions,
604 Files: s.hl.files(ctx, res.Diff.Files),
605 }
606 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
607 src := ""
608 if !res.DiffBaseID.IsZero() {
609 src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
610 }
611 collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
612 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
613 return nil, 0, err
614 }
615
616 p.Note = diffNote(res.Diff)
617 countNotes(p.Files, res.Comments)
618 if !res.DiffBaseID.IsZero() {
619 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
620 }
621 p.From = from
622
623 s.mrReadPresentation(&p, d.name(), n, res, since, signed)
624
625 // Comments: under their line, else in the list below the diff. The forms
626 // are the owner's, on the whole diff of an open request.
627 canWrite := signed && open && !since
628 var on *lineRef
629 if canWrite {
630 on = parseOn(r.URL.Query().Get("on"))
631 }
632 s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
633 lines, invisible := diffLines(p.Files)
634 p.Invisible = invisible
635 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
636 if signed && open {
637 p.Box = s.mrBox(ctx, d, res)
638 }
639
640 if mergeErrorOf(ctx) != "" {
641 p.Canonical = self
642 }
643 if p.Box == nil {
644 p.MergeError = mergeErrorOf(ctx) // the request is not open any more
645 }
646 if canWrite {
647 p.GeneralForm = &CommentForm{
648 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
649 }
650 }
651
652 l := s.repoLayout(ctx, d, "merge-requests")
653 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654 if head != "" {
655 pinRef(&l, target{rev: head, name: m.Source})
656 if !d.hasBranch(m.Source) {
657 pinRef(&l, target{rev: head, name: short(head), detached: true})
658 }
659 }
660 l.Crumbs[0].Current = false
661 l.Crumbs = append(l.Crumbs,
662 Crumb{Label: "merge requests", Href: mrBase(d.name())},
663 Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
664 )
665 if since {
666 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
667 }
668 p.Live = true
669 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))
670 l.HasRail = true
671 l.MR, l.Lazy = true, true
672 return MergeRequest(l, p), http.StatusOK, nil
673}
674
675func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
676 m := res.MergeRequest
677 box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
678 box.Error = mergeErrorOf(ctx)
679 if m.IsDraft() {
680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
681 }
682 box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
683 return box
684}
685
686// autoMergeView is the pending auto-merge ready to show, nil when off.
687func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688 if am == nil {
689 return nil
690 }
691 v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692 v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693 return v
694}
695
696// mrFacts formats the description, metadata, branch links and checks.
697func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
698 m := res.MergeRequest
699 head := m.HeadID.String()
700 signed := SignedIn(ctx)
701 open := m.State == domain.MRStateOpen
702 now := s.cfg.Now()
703 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
704 // Relative links point into the source branch as it is now (its head
705 // commit); the heading ids carry their own prefix, apart from the
706 // ids of the diff.
707 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
708 if err != nil {
709 return err
710 }
711 p.Desc = h
712 }
713 for _, v := range res.Verdicts {
714 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())})
715 }
716 p.Opened.Name = p.Author
717 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
718 if !m.CreatedAt.IsZero() {
719 p.Opened.Abs = absolute(m.CreatedAt)
720 }
721 p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
722 for _, b := range d.branches {
723 switch b.ShortName {
724 case m.Source:
725 p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
726 case m.Target:
727 p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
728 }
729 }
730 if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
731 p.MergedShort = short(m.MergedCommitID.String())
732 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
733 p.MergeMode = modeName(m.MergeMode)
734 }
735 if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
736 p.Deleting = visibleText(m.Source)
737 }
738 for _, mode := range d.repo.Settings.AllowedMergeModes {
739 if name := modeName(mode); name != "" {
740 p.Modes = append(p.Modes, name)
741 }
742 }
743 p.ChecksBlock = true
744 if head != "" {
745 checks := s.checksFor(ctx, d.repo.Name, head)
746 p.Checks = checksSection(d.repo.Repo, checks)
747 p.ChecksSum = checksSummary(d.repo.Name, head, checks)
748 if !open {
749 p.Checks.Note = ""
750 }
751 }
752 return nil
753}
754
755// mrReadPresentation maps usecase read tracking to commit rows and navigation.
756func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
757 now := s.cfg.Now()
758 head := res.MergeRequest.HeadID.String()
759 // The commits of the request, newest first.
760 for i, c := range res.Commits {
761 row := commitRow(name, c, now)
762 p.Commits = append(p.Commits, MRCommit{
763 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
764 Sig: row.Sig, New: signed && i < res.NewCommits,
765 })
766 }
767 lastRead := res.LastReadID.String()
768 p.CommitCount = itoa(len(p.Commits))
769 if res.CommitsTruncated {
770 p.CommitCount += "+"
771 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
772 }
773
774 // Since the last read: the navigation, and what the page says about it.
775 if signed && lastRead != "" {
776 p.Since = &SinceNav{
777 AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
778 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
779 }
780 }
781 if since {
782 switch {
783 case lastRead == "":
784 p.SinceNote = "nothing was marked read yet: this is the whole change."
785 case res.LastReadLost:
786 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
787 case len(p.Files) == 0:
788 p.SinceNote = "nothing changed since " + short(lastRead) + "."
789 default:
790 p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
791 }
792 }
793 if p.Since != nil {
794 p.Since.Files = len(p.Files)
795 }
796
797}
798
799// hasBranch tells whether the repository has the branch.
800func (d *repoData) hasBranch(name string) bool {
801 for _, b := range d.branches {
802 if b.ShortName == name {
803 return true
804 }
805 }
806 return false
807}
808
809// mergeBox is what the owner can do with an open request.
810func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
811 b := &MergeBox{
812 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
813 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
814 CanMarkRead: canMarkRead,
815 CanDeleteBranch: source != d.repo.DefaultBranch,
816 Mergeability: mg,
817 AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818 CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
819 }
820 var lines []string
821 cmd := ""
822 if head != "" {
823 cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
824 }
825 for _, mode := range d.repo.Settings.AllowedMergeModes {
826 switch mode {
827 case domain.MergeModeFastForward:
828 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
829 lines = append(lines, cmd)
830 case domain.MergeModeSquash:
831 b.Signed = true
832 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
833 lines = append(lines, cmd+" --mode squash")
834 case domain.MergeModeMerge:
835 b.Signed = true
836 b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
837 lines = append(lines, cmd+" --mode merge")
838 }
839 }
840 if b.Signed && s.cfg.SigningKey != nil {
841 b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
842 }
843 if head != "" {
844 b.CLI = Clone{ID: "cli-merge", Lines: lines}
845 }
846 return b
847}
848
849func reasonIf(cond bool, reason string) string {
850 if cond {
851 return reason
852 }
853 return ""
854}
855
856// firstModeOK is the index of the first mode that is possible, -1 for none.
857func (b *MergeBox) firstModeOK() int {
858 for i, m := range b.Modes {
859 if !m.Disabled {
860 return i
861 }
862 }
863 return -1
864}
865
866// Checked is the mode the form has checked: the first that is possible, else
867// the first (it is disabled, and a live update may enable it).
868func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
869
870// CanMerge tells whether some mode is possible: the button is enabled.
871func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
872
873// MergeTitle is why the button is disabled, empty when it is not.
874func (b *MergeBox) MergeTitle() string {
875 if b.CanMerge() {
876 return ""
877 }
878 if len(b.Modes) == 0 {
879 return "the repository allows no merge mode"
880 }
881 return b.Modes[0].Reason
882}
883
884// shellWord quotes s for a shell unless it is made of safe characters, which
885// a repository name always is.
886func shellWord(s string) string {
887 for _, c := range []byte(s) {
888 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
889 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
890 }
891 }
892 return s
893}
894
895// lineRef designates a line of the diff in the query of the comment form:
896// "?on=new:12:path/to/file".
897type lineRef struct {
898 side string
899 line int
900 start int
901 path string
902}
903
904func parseOn(s string) *lineRef {
905 side, rest, ok := strings.Cut(s, ":")
906 if !ok || (side != "new" && side != "old") {
907 return nil
908 }
909 num, path, ok := strings.Cut(rest, ":")
910 start := 0
911 if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
912 var e error
913 start, e = strconv.Atoi(a)
914 end, e2 := strconv.Atoi(b)
915 if e != nil || e2 != nil || start < 1 || start >= end {
916 return nil
917 }
918 num = b
919 }
920 line, err := strconv.Atoi(num)
921 if !ok || err != nil || line < 1 || path == "" {
922 return nil
923 }
924 return &lineRef{side: side, line: line, start: start, path: path}
925}
926
927// placeComments puts every comment under its line when the diff shown has it,
928// else in the list below the diff. The comments name lines of the diff against
929// the target, so a page that shows another diff (since the last read) lists
930// them all below, with their line. A reader who can write gets the line numbers
931// as links to the form of their line; on designates the line whose form shows.
932//
933//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
934func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
935 now := s.cfg.Now()
936 self := mrHref(repo, n)
937 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
938
939 type lineKey struct {
940 path string
941 side domain.DiffSide
942 line int
943 }
944 index := map[lineKey]*DiffLine{}
945 fileIDs := map[string]string{}
946 fileAt := map[string]int{}
947 for fi := range p.Files {
948 f := &p.Files[fi]
949 fileIDs[f.Key] = f.ID
950 fileAt[f.Key] = fi
951 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
952 // The form of a line shows on the page of the window of its file.
953 on := self + "?on="
954 if w := windowStart(fi); w > 0 {
955 on = self + "?from=" + itoa(w) + "&on="
956 }
957 for hi := range f.Hunks {
958 for li := range f.Hunks[hi].Lines {
959 ln := &f.Hunks[hi].Lines[li]
960 if ln.oldN > 0 && ln.Kind != "add" {
961 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
962 if linkable {
963 ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
964 }
965 }
966 if ln.newN > 0 && ln.Kind != "del" {
967 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
968 if linkable {
969 ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
970 }
971 }
972 }
973 }
974 }
975 find := func(path string, side domain.DiffSide, line int) *DiffLine {
976 if !inline {
977 return nil
978 }
979 return index[lineKey{path, side, line}]
980 }
981
982 exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
983 children := map[int64][]domain.Comment{}
984 for _, c := range comments {
985 if c.ReplyTo != 0 && !c.Deleted {
986 children[c.ReplyTo] = append(children[c.ReplyTo], c)
987 }
988 if !c.Deleted {
989 p.NComments++
990 }
991 if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
992 p.NUnresolved++
993 }
994 }
995 var view func(domain.Comment, bool) CommentView
996 view = func(c domain.Comment, replyable bool) CommentView {
997 cid := strconv.FormatInt(c.ID, 10)
998 body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
999 if err != nil {
1000 body = ""
1001 }
1002 bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
1003 if err != nil {
1004 bodySplit = ""
1005 }
1006 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}
1007 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
1008 if !c.CreatedAt.IsZero() {
1009 v.Abs = absolute(c.CreatedAt)
1010 }
1011 if c.StartLine > 0 {
1012 v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
1013 }
1014 if canWrite && !c.Deleted {
1015 from := windowStart(fileAt[c.Path])
1016 root := cid
1017 if c.ReplyTo != 0 {
1018 root = strconv.FormatInt(c.ReplyTo, 10)
1019 }
1020 if replyable {
1021 v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
1022 }
1023 if c.ReplyTo == 0 {
1024 v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
1025 }
1026 if c.Author == p.NoteAuthor {
1027 v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
1028 v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
1029 }
1030 }
1031 for _, reply := range children[c.ID] {
1032 v.Replies = append(v.Replies, view(reply, replyable))
1033 }
1034 return v
1035 }
1036 for _, c := range comments {
1037 if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
1038 continue
1039 }
1040 cid := strconv.FormatInt(c.ID, 10)
1041 v := view(c, canWrite && !c.Deleted)
1042 general := c.Path == ""
1043 fi, listed := fileAt[c.Path]
1044 if !general && listed && p.Files[fi].Lazy {
1045 // Its file comes with its window, and the comment with it.
1046 continue
1047 }
1048 var ln *DiffLine
1049 if !general {
1050 ln = find(c.Path, c.Side, c.Line)
1051 if ln == nil {
1052 side := "new"
1053 if c.Side == domain.SideOld {
1054 side = "old"
1055 }
1056 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
1057 }
1058 }
1059 if canWrite && !c.Deleted {
1060 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
1061 if !general && listed {
1062 v.Resolve.From = windowStart(fi)
1063 }
1064 if c.Resolved {
1065 v.Resolve.Label = "Unresolve"
1066 }
1067 }
1068 switch {
1069 case ln != nil:
1070 ln.Notes = append(ln.Notes, v)
1071 case general:
1072 p.General = append(p.General, v)
1073 case p.LooseInFiles && listed:
1074 p.Files[fi].Loose = append(p.Files[fi].Loose, v)
1075 default:
1076 p.Loose = append(p.Loose, v)
1077 }
1078 }
1079
1080 if on != nil {
1081 side := domain.SideNew
1082 if on.side == "old" {
1083 side = domain.SideOld
1084 }
1085 start := on.start
1086 if start == 0 {
1087 start = on.line
1088 }
1089 for key, ln := range index {
1090 if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
1091 ln.Selected = true
1092 }
1093 }
1094 // The title names the whole range a shift + click selected: a.go:21-149.
1095 lines := itoa(on.line)
1096 if start < on.line {
1097 lines = itoa(start) + "-" + lines
1098 }
1099 if ln := find(on.path, side, on.line); ln != nil {
1100 ln.Form = &CommentForm{
1101 StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
1102 Title: "comment on " + visibleText(on.path) + ":" + lines + " (" + on.side + ")",
1103 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
1104 From: windowStart(fileAt[on.path]),
1105 }
1106 }
1107 }
1108}
1109
1110// userText renders the parts of a plain text: the text escaped, the http(s)
1111// addresses as links. Its white space is kept by the style of the container.
1112func userText(parts []TextPart) templ.Component {
1113 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
1114 var b strings.Builder
1115 for _, part := range parts {
1116 if part.Href != "" {
1117 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
1118 } else {
1119 b.WriteString(html.EscapeString(part.Text))
1120 }
1121 }
1122 _, err := io.WriteString(w, b.String())
1123 return err
1124 })
1125}
1126
1127func noteIDs(comments []domain.Comment) func(int64) bool {
1128 ids := make(map[int64]bool, len(comments))
1129 for _, c := range comments {
1130 ids[c.ID] = true
1131 }
1132 return func(n int64) bool { return ids[n] }
1133}
1134func optionalLine(n int) string {
1135 if n == 0 {
1136 return ""
1137 }
1138 return itoa(n)
1139}