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