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