1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "html"
8 "html/template"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14
15 "github.com/a-h/templ"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19)
20
21// The merge request pages (ADR 0017). They read through MergeRequestService
22// and BrowseService as the visitor of the request: anonymous sees the merge
23// requests of public repositories, the signed-in owner those of every
24// repository, and only the owner gets the forms (mr_post.go handles them).
25//
26// Routes:
27//
28// GET /<repo>/merge-requests the list, open ones first
29// GET /<repo>/merge-requests/<n> the request with its diff
30// GET /<repo>/merge-requests/<n>/since the diff since the last read
31// GET /<repo>/compare/<branch> a branch against the default branch
32// GET /<repo>/merge-requests/<n>/files a window of files of the diff
33//
34// The page lists every changed file and reads the hunks of the first window
35// of files only (mrWindow); the files after it are placeholders that the
36// browser fills in, a window per request, as they come near the screen
37// (/files?from=&base=&head=). Without scripts each placeholder links to the
38// page of its window (?from=), which shows the hunks of that window.
39//
40// Reading marks nothing as read. MarkRead stores state, and a GET must stay
41// safe: a prefetcher, a link preview or a reload of the owner's tab would move
42// the read position, and the "since your last read" diff would lose what the
43// owner never saw. So the owner marks a head read with a POST button
44// ("[mark read]", /<n>/read), which carries the head the page showed; the
45// button only shows when that head is not the one already read. The since page
46// is the owner's alone (the position is the owner's), and answers 404 to
47// anyone else.
48//
49// Every page here is rendered through page(), so for the owner it is
50// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
51// forms and the CLI lines exist only in the owner's copy.
52
53const (
54 // mrWindow is how many files of a merge request one read gets the hunks
55 // of: the page shows the first window and loads the others.
56 mrWindow = 25
57 // mrPerPage is how many decided merge requests a page of the list shows,
58 // at least; pages of the use case are read whole.
59 mrPerPage = 50
60 // maxMRPages bounds the pages of the use case one list page reads.
61 maxMRPages = 20
62 // maxOpenPages bounds the pages read for the open merge requests, which
63 // the first list page shows in full.
64 maxOpenPages = 5
65 // railBranches is how many branches the list offers to compare.
66 railBranches = 15
67)
68
69// MRRow is one merge request in the list.
70type MRRow struct {
71 Number string
72 Href string
73 Title string
74 Branches string
75 Author string
76 State string
77 When string
78 WhenAt string
79 Cursor bool
80}
81
82// BranchLink is a branch the list offers to open a merge request for.
83type BranchLink struct{ Name, Href string }
84
85// MRListPage is the list of the merge requests of a repository.
86type MRListPage struct {
87 Open, Done []MRRow
88 // OpenNote says the open list was cut.
89 OpenNote string
90 // Older and Newest are the paging links of the decided requests.
91 Older, Newest string
92 Branches []BranchLink
93 Default string
94 CLI Clone
95 // Live is true when the page announces changes.
96 Live bool
97}
98
99// CommentView is a comment of a merge request, ready to show.
100type CommentView struct {
101 ID string
102 Author string
103 When string
104 WhenAt string
105 Abs string
106 Body, BodySplit template.HTML
107 // Parts is a plain-text body (an issue comment); Body is the Markdown of
108 // a merge request note.
109 Parts []TextPart
110 Replies []CommentView
111 Edited, Deleted bool
112 Verdict, Range, Quote string
113 Edit *EditForm
114 Delete *DeleteForm
115 Reply *CommentForm
116 Resolved bool
117 // Outdated is true when the head moved since the comment was written, so
118 // its line may have moved too.
119 Outdated bool
120 // Where names the line of a comment that is not under a line of the diff
121 // shown ("path:12 (new)").
122 Where string
123 // Resolve is the form that resolves or unresolves it; nil when the reader
124 // cannot.
125 Resolve *ResolveForm
126}
127
128type EditForm struct {
129 Action, Text string
130 From int
131}
132type DeleteForm struct {
133 Action string
134 From int
135}
136type VerdictView struct {
137 Author, Label, Href, Short string
138 Stale bool
139}
140
141// ResolveForm is the POST that changes the resolved state of a comment.
142type ResolveForm struct {
143 Action string
144 // Value is the state the form sets.
145 Value bool
146 Label string
147 // From is the window of the page the comment is on (CommentForm.From).
148 From int
149}
150
151// CommentForm is the POST that adds a comment.
152type CommentForm struct {
153 ID string
154 Action string
155 Title string
156 // Path, Line and Side designate the line; empty for a general comment.
157 Path, Line, Side string
158 StartLine, ReplyTo string
159 // Plain is a form of an issue comment: plain text, no verdict.
160 Plain bool
161 Cancel string
162 Focus bool
163 // From is the window of the page the form is on (see mrWindow), sent
164 // back so that the answer returns to it; 0 for the first.
165 From int
166}
167
168// MRCommit is a commit of the request.
169type MRCommit struct {
170 Short, Href, Subject, Author, When, WhenAt string
171 // New is true for a commit pushed after the head the reviewer last read.
172 New bool
173 // Sig is the signature badge, nil for an unsigned or unchecked commit.
174 Sig *SignatureBadge
175}
176
177// MergeBox is what the owner does with an open request.
178type MergeBox struct {
179 MergeAction string
180 CloseAction string
181 // ReadyAction takes a draft out of draft; empty when it is not one: a
182 // draft shows it in place of the merge form.
183 ReadyAction string
184 ReadAction string
185 Head string
186 Modes []ModeOption
187 // HeadGone is true when the source branch no longer exists: there is
188 // nothing to merge.
189 HeadGone bool
190 CLI Clone
191 // CanMarkRead is true when the head differs from the one last read.
192 CanMarkRead bool
193 // CanDeleteBranch offers to delete the source after the merge: it is not
194 // the default branch. The server checks again, with the open requests.
195 CanDeleteBranch bool
196 // Mergeability is the live badge of the box and what the modes depend on.
197 Mergeability MergeabilityView
198 // Signed is true when a mode that creates a commit (squash, merge commit)
199 // is offered; Fingerprint is then the one of the key that signs it, empty
200 // when the server does not tell.
201 Signed bool
202 Fingerprint string
203 // Error is why the last merge was refused, shown in the box.
204 Error string
205}
206
207// ModeOption is a merge mode the repository allows. Disabled says that the
208// mode is not possible for the current state of the request, Reason why.
209type ModeOption struct {
210 Value, Label string
211 Disabled bool
212 Reason string
213}
214
215// MergeabilityView is the mergeability of an open request, ready to show: the
216// badge, and what the merge modes depend on (the flags are what the controls
217// of the box read, live).
218type MergeabilityView struct {
219 State, Label, Detail string
220 Paths []string
221 // PathsNote says that more paths conflict than are listed.
222 PathsNote string
223 // FFOk and MergeOk say whether fast-forward, squash and merge commit are
224 // possible; the reasons say why not.
225 FFOk, MergeOk bool
226 FFReason, MergeReason string
227}
228
229// SinceNav switches between the whole diff and the diff since the last read.
230type SinceNav struct {
231 AllHref, SinceHref string
232 Files int
233 ReadShort string
234 HeadShort string
235 Current bool
236}
237
238// MRPage is a merge request with its diff and comments.
239type MRPage struct {
240 Number string
241 Title string
242 State string
243 Open bool
244 Signed bool
245 NoteAuthor string
246 Source, Target string
247 SourceHref string
248 TargetHref string
249 Author string
250 Opened Person
251 Updated string
252 UpdatedAt string
253 // Desc is the description rendered as Markdown (as a README: no raw
254 // HTML, filtered URLs), "" when it is empty.
255 Desc template.HTML
256 Head string
257 HeadShort string
258 MergedShort string
259 MergedHref string
260 MergeMode string
261 // Deleting is the source branch that a merge just asked to delete: the
262 // deletion follows the answer.
263 Deleting string
264 // MergeError is why a merge was refused when the page has no merge box
265 // to show it in (the request is not open any more).
266 MergeError string
267 // Canonical is the address of the request, set when the page answers a
268 // refused action from the address of that action: the script puts it in
269 // the address bar, so that a reload does not post again.
270 Canonical string
271
272 Since *SinceNav
273 SinceNote string
274
275 NFiles, Adds, Dels int
276 Files []DiffFile
277 DiffView
278 Note string
279 Invisible bool
280
281 // General are the comments that are not on a line, Loose those on a line
282 // that the diff shown does not have. With LooseInFiles (a window of files
283 // loaded later) the loose comments of a listed file go under the file
284 // instead (DiffFile.Loose).
285 General, Loose []CommentView
286 LooseInFiles bool
287 NComments int
288 NUnresolved int
289 GeneralForm *CommentForm
290 Verdicts []VerdictView
291
292 Commits []MRCommit
293 CommitsNote string
294 CommitCount 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 Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
566 })
567 if err != nil {
568 return nil, 0, err
569 }
570 if from > 0 && from >= len(res.Diff.Files) {
571 return nil, 0, errNotFound
572 }
573 m := res.MergeRequest
574 signed := SignedIn(ctx)
575 open := m.State == domain.MRStateOpen
576 head := m.HeadID.String()
577 self := mrHref(d.name(), n)
578 if since {
579 self = mrHref(d.name(), n, "since")
580 }
581
582 p := MRPage{
583 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
584 State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
585 Source: visibleText(m.Source), Target: visibleText(m.Target),
586 Author: s.who(m.Author),
587 Head: head, HeadShort: short(head),
588 NFiles: res.Stats.FilesChanged,
589 Adds: res.Stats.Additions,
590 Dels: res.Stats.Deletions,
591 Files: s.hl.files(ctx, res.Diff.Files),
592 }
593 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
594 src := ""
595 if !res.DiffBaseID.IsZero() {
596 src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
597 }
598 collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
599 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
600 return nil, 0, err
601 }
602
603 p.Note = diffNote(res.Diff)
604 countNotes(p.Files, res.Comments)
605 if !res.DiffBaseID.IsZero() {
606 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
607 }
608 p.From = from
609
610 s.mrReadPresentation(&p, d.name(), n, res, since, signed)
611
612 // Comments: under their line, else in the list below the diff. The forms
613 // are the owner's, on the whole diff of an open request.
614 canWrite := signed && open && !since
615 var on *lineRef
616 if canWrite {
617 on = parseOn(r.URL.Query().Get("on"))
618 }
619 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
620 lines, invisible := diffLines(p.Files)
621 p.Invisible = invisible
622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
623 if signed && open {
624 p.Box = s.mrBox(ctx, d, res)
625 }
626
627 if mergeErrorOf(ctx) != "" {
628 p.Canonical = self
629 }
630 if p.Box == nil {
631 p.MergeError = mergeErrorOf(ctx) // the request is not open any more
632 }
633 if canWrite {
634 p.GeneralForm = &CommentForm{
635 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
636 }
637 }
638
639 l := s.repoLayout(ctx, d, "merge-requests")
640 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
641 if head != "" {
642 pinRef(&l, target{rev: head, name: m.Source})
643 if !d.hasBranch(m.Source) {
644 pinRef(&l, target{rev: head, name: short(head), detached: true})
645 }
646 }
647 l.Crumbs[0].Current = false
648 l.Crumbs = append(l.Crumbs,
649 Crumb{Label: "merge requests", Href: mrBase(d.name())},
650 Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
651 )
652 if since {
653 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
654 }
655 p.Live = true
656 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))
657 l.HasRail = true
658 l.MR, l.Lazy = true, true
659 return MergeRequest(l, p), http.StatusOK, nil
660}
661
662func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
663 m := res.MergeRequest
664 box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
665 box.Error = mergeErrorOf(ctx)
666 if m.IsDraft() {
667 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668 }
669 return box
670}
671
672// mrFacts formats the description, metadata, branch links and checks.
673func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674 m := res.MergeRequest
675 head := m.HeadID.String()
676 signed := SignedIn(ctx)
677 open := m.State == domain.MRStateOpen
678 now := s.cfg.Now()
679 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
680 // Relative links point into the source branch as it is now (its head
681 // commit); the heading ids carry their own prefix, apart from the
682 // ids of the diff.
683 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
684 if err != nil {
685 return err
686 }
687 p.Desc = h
688 }
689 for _, v := range res.Verdicts {
690 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())})
691 }
692 p.Opened.Name = p.Author
693 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
694 if !m.CreatedAt.IsZero() {
695 p.Opened.Abs = absolute(m.CreatedAt)
696 }
697 p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
698 for _, b := range d.branches {
699 switch b.ShortName {
700 case m.Source:
701 p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
702 case m.Target:
703 p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
704 }
705 }
706 if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
707 p.MergedShort = short(m.MergedCommitID.String())
708 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
709 p.MergeMode = modeName(m.MergeMode)
710 }
711 if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
712 p.Deleting = visibleText(m.Source)
713 }
714 for _, mode := range d.repo.Settings.AllowedMergeModes {
715 if name := modeName(mode); name != "" {
716 p.Modes = append(p.Modes, name)
717 }
718 }
719 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
720 if head != "" {
721 checks := s.checksFor(ctx, d.repo.Name, head)
722 p.Checks = checksSection(d.repo.Repo, checks)
723 p.ChecksSum = checksSummary(d.repo.Name, head, checks)
724 if !open {
725 p.Checks.Note = ""
726 }
727 }
728 return nil
729}
730
731// mrReadPresentation maps usecase read tracking to commit rows and navigation.
732func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
733 now := s.cfg.Now()
734 head := res.MergeRequest.HeadID.String()
735 // The commits of the request, newest first.
736 for i, c := range res.Commits {
737 row := commitRow(name, c, now)
738 p.Commits = append(p.Commits, MRCommit{
739 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
740 Sig: row.Sig, New: signed && i < res.NewCommits,
741 })
742 }
743 lastRead := res.LastReadID.String()
744 p.CommitCount = itoa(len(p.Commits))
745 if res.CommitsTruncated {
746 p.CommitCount += "+"
747 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
748 }
749
750 // Since the last read: the navigation, and what the page says about it.
751 if signed && lastRead != "" {
752 p.Since = &SinceNav{
753 AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
754 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
755 }
756 }
757 if since {
758 switch {
759 case lastRead == "":
760 p.SinceNote = "nothing was marked read yet: this is the whole change."
761 case res.LastReadLost:
762 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
763 case len(p.Files) == 0:
764 p.SinceNote = "nothing changed since " + short(lastRead) + "."
765 default:
766 p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
767 }
768 }
769 if p.Since != nil {
770 p.Since.Files = len(p.Files)
771 }
772
773}
774
775// hasBranch tells whether the repository has the branch.
776func (d *repoData) hasBranch(name string) bool {
777 for _, b := range d.branches {
778 if b.ShortName == name {
779 return true
780 }
781 }
782 return false
783}
784
785// mergeBox is what the owner can do with an open request.
786func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
787 b := &MergeBox{
788 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
789 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
790 CanMarkRead: canMarkRead,
791 CanDeleteBranch: source != d.repo.DefaultBranch,
792 Mergeability: mg,
793 }
794 var lines []string
795 cmd := ""
796 if head != "" {
797 cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
798 }
799 for _, mode := range d.repo.Settings.AllowedMergeModes {
800 switch mode {
801 case domain.MergeModeFastForward:
802 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
803 lines = append(lines, cmd)
804 case domain.MergeModeSquash:
805 b.Signed = true
806 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
807 lines = append(lines, cmd+" --mode squash")
808 case domain.MergeModeMerge:
809 b.Signed = true
810 b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
811 lines = append(lines, cmd+" --mode merge")
812 }
813 }
814 if b.Signed && s.cfg.SigningKey != nil {
815 b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
816 }
817 if head != "" {
818 b.CLI = Clone{ID: "cli-merge", Lines: lines}
819 }
820 return b
821}
822
823func reasonIf(cond bool, reason string) string {
824 if cond {
825 return reason
826 }
827 return ""
828}
829
830// firstModeOK is the index of the first mode that is possible, -1 for none.
831func (b *MergeBox) firstModeOK() int {
832 for i, m := range b.Modes {
833 if !m.Disabled {
834 return i
835 }
836 }
837 return -1
838}
839
840// Checked is the mode the form has checked: the first that is possible, else
841// the first (it is disabled, and a live update may enable it).
842func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
843
844// CanMerge tells whether some mode is possible: the button is enabled.
845func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
846
847// MergeTitle is why the button is disabled, empty when it is not.
848func (b *MergeBox) MergeTitle() string {
849 if b.CanMerge() {
850 return ""
851 }
852 if len(b.Modes) == 0 {
853 return "the repository allows no merge mode"
854 }
855 return b.Modes[0].Reason
856}
857
858// shellWord quotes s for a shell unless it is made of safe characters, which
859// a repository name always is.
860func shellWord(s string) string {
861 for _, c := range []byte(s) {
862 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
863 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
864 }
865 }
866 return s
867}
868
869// lineRef designates a line of the diff in the query of the comment form:
870// "?on=new:12:path/to/file".
871type lineRef struct {
872 side string
873 line int
874 start int
875 path string
876}
877
878func parseOn(s string) *lineRef {
879 side, rest, ok := strings.Cut(s, ":")
880 if !ok || (side != "new" && side != "old") {
881 return nil
882 }
883 num, path, ok := strings.Cut(rest, ":")
884 start := 0
885 if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
886 var e error
887 start, e = strconv.Atoi(a)
888 end, e2 := strconv.Atoi(b)
889 if e != nil || e2 != nil || start < 1 || start >= end {
890 return nil
891 }
892 num = b
893 }
894 line, err := strconv.Atoi(num)
895 if !ok || err != nil || line < 1 || path == "" {
896 return nil
897 }
898 return &lineRef{side: side, line: line, start: start, path: path}
899}
900
901// placeComments puts every comment under its line when the diff shown has it,
902// else in the list below the diff. The comments name lines of the diff against
903// the target, so a page that shows another diff (since the last read) lists
904// them all below, with their line. A reader who can write gets the line numbers
905// as links to the form of their line; on designates the line whose form shows.
906//
907//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909 now := s.cfg.Now()
910 self := mrHref(repo, n)
911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
912
913 type lineKey struct {
914 path string
915 side domain.DiffSide
916 line int
917 }
918 index := map[lineKey]*DiffLine{}
919 fileIDs := map[string]string{}
920 fileAt := map[string]int{}
921 for fi := range p.Files {
922 f := &p.Files[fi]
923 fileIDs[f.Key] = f.ID
924 fileAt[f.Key] = fi
925 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
926 // The form of a line shows on the page of the window of its file.
927 on := self + "?on="
928 if w := windowStart(fi); w > 0 {
929 on = self + "?from=" + itoa(w) + "&on="
930 }
931 for hi := range f.Hunks {
932 for li := range f.Hunks[hi].Lines {
933 ln := &f.Hunks[hi].Lines[li]
934 if ln.oldN > 0 && ln.Kind != "add" {
935 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
936 if linkable {
937 ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
938 }
939 }
940 if ln.newN > 0 && ln.Kind != "del" {
941 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
942 if linkable {
943 ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
944 }
945 }
946 }
947 }
948 }
949 find := func(path string, side domain.DiffSide, line int) *DiffLine {
950 if !inline {
951 return nil
952 }
953 return index[lineKey{path, side, line}]
954 }
955
956 exists := noteIDs(comments)
957 children := map[int64][]domain.Comment{}
958 for _, c := range comments {
959 if c.ReplyTo != 0 && !c.Deleted {
960 children[c.ReplyTo] = append(children[c.ReplyTo], c)
961 }
962 if !c.Deleted {
963 p.NComments++
964 }
965 if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
966 p.NUnresolved++
967 }
968 }
969 var view func(domain.Comment, bool) CommentView
970 view = func(c domain.Comment, replyable bool) CommentView {
971 cid := strconv.FormatInt(c.ID, 10)
972 body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
973 if err != nil {
974 body = ""
975 }
976 bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
977 if err != nil {
978 bodySplit = ""
979 }
980 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}
981 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
982 if !c.CreatedAt.IsZero() {
983 v.Abs = absolute(c.CreatedAt)
984 }
985 if c.StartLine > 0 {
986 v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
987 }
988 if canWrite && !c.Deleted {
989 from := windowStart(fileAt[c.Path])
990 root := cid
991 if c.ReplyTo != 0 {
992 root = strconv.FormatInt(c.ReplyTo, 10)
993 }
994 if replyable {
995 v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
996 }
997 if c.ReplyTo == 0 {
998 v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
999 }
1000 if c.Author == p.NoteAuthor {
1001 v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
1002 v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
1003 }
1004 }
1005 for _, reply := range children[c.ID] {
1006 v.Replies = append(v.Replies, view(reply, replyable))
1007 }
1008 return v
1009 }
1010 for _, c := range comments {
1011 if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
1012 continue
1013 }
1014 cid := strconv.FormatInt(c.ID, 10)
1015 v := view(c, canWrite && !c.Deleted)
1016 general := c.Path == ""
1017 fi, listed := fileAt[c.Path]
1018 if !general && listed && p.Files[fi].Lazy {
1019 // Its file comes with its window, and the comment with it.
1020 continue
1021 }
1022 var ln *DiffLine
1023 if !general {
1024 ln = find(c.Path, c.Side, c.Line)
1025 if ln == nil {
1026 side := "new"
1027 if c.Side == domain.SideOld {
1028 side = "old"
1029 }
1030 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
1031 }
1032 }
1033 if canWrite && !c.Deleted {
1034 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
1035 if !general && listed {
1036 v.Resolve.From = windowStart(fi)
1037 }
1038 if c.Resolved {
1039 v.Resolve.Label = "Unresolve"
1040 }
1041 }
1042 switch {
1043 case ln != nil:
1044 ln.Notes = append(ln.Notes, v)
1045 case general:
1046 p.General = append(p.General, v)
1047 case p.LooseInFiles && listed:
1048 p.Files[fi].Loose = append(p.Files[fi].Loose, v)
1049 default:
1050 p.Loose = append(p.Loose, v)
1051 }
1052 }
1053
1054 if on != nil {
1055 side := domain.SideNew
1056 if on.side == "old" {
1057 side = domain.SideOld
1058 }
1059 start := on.start
1060 if start == 0 {
1061 start = on.line
1062 }
1063 for key, ln := range index {
1064 if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
1065 ln.Selected = true
1066 }
1067 }
1068 // The title names the whole range a shift + click selected: a.go:21-149.
1069 lines := itoa(on.line)
1070 if start < on.line {
1071 lines = itoa(start) + "-" + lines
1072 }
1073 if ln := find(on.path, side, on.line); ln != nil {
1074 ln.Form = &CommentForm{
1075 StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
1076 Title: "comment on " + visibleText(on.path) + ":" + lines + " (" + on.side + ")",
1077 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
1078 From: windowStart(fileAt[on.path]),
1079 }
1080 }
1081 }
1082}
1083
1084// userText renders the parts of a plain text: the text escaped, the http(s)
1085// addresses as links. Its white space is kept by the style of the container.
1086func userText(parts []TextPart) templ.Component {
1087 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
1088 var b strings.Builder
1089 for _, part := range parts {
1090 if part.Href != "" {
1091 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
1092 } else {
1093 b.WriteString(html.EscapeString(part.Text))
1094 }
1095 }
1096 _, err := io.WriteString(w, b.String())
1097 return err
1098 })
1099}
1100
1101func noteIDs(comments []domain.Comment) func(int64) bool {
1102 ids := make(map[int64]bool, len(comments))
1103 for _, c := range comments {
1104 ids[c.ID] = true
1105 }
1106 return func(n int64) bool { return ids[n] }
1107}
1108func optionalLine(n int) string {
1109 if n == 0 {
1110 return ""
1111 }
1112 return itoa(n)
1113}