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