detached 0e6522c

mr.go RawHistoryFolder

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	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	Cancel             string
160	Focus              bool
161	// From is the window of the page the form is on (see mrWindow), sent
162	// back so that the answer returns to it; 0 for the first.
163	From int
164}
165
166// MRCommit is a commit of the request.
167type MRCommit struct {
168	Short, Href, Subject, Author, When, WhenAt string
169	// New is true for a commit pushed after the head the reviewer last read.
170	New bool
171	// Sig is the signature badge, nil for an unsigned or unchecked commit.
172	Sig *SignatureBadge
173}
174
175// MergeBox is what the owner does with an open request.
176type MergeBox struct {
177	MergeAction string
178	CloseAction string
179	// ReadyAction takes a draft out of draft; empty when it is not one: a
180	// draft shows it in place of the merge form.
181	ReadyAction string
182	ReadAction  string
183	Head        string
184	Modes       []ModeOption
185	// HeadGone is true when the source branch no longer exists: there is
186	// nothing to merge.
187	HeadGone bool
188	CLI      Clone
189	// CanMarkRead is true when the head differs from the one last read.
190	CanMarkRead bool
191	// CanDeleteBranch offers to delete the source after the merge: it is not
192	// the default branch. The server checks again, with the open requests.
193	CanDeleteBranch bool
194}
195
196// ModeOption is a merge mode the repository allows.
197type ModeOption struct{ Value, Label string }
198
199// SinceNav switches between the whole diff and the diff since the last read.
200type SinceNav struct {
201	AllHref, SinceHref string
202	Files              int
203	ReadShort          string
204	HeadShort          string
205	Current            bool
206}
207
208// MRPage is a merge request with its diff and comments.
209type MRPage struct {
210	Number         string
211	Title          string
212	State          string
213	Open           bool
214	Signed         bool
215	NoteAuthor     string
216	Source, Target string
217	SourceHref     string
218	TargetHref     string
219	Author         string
220	Opened         Person
221	Updated        string
222	UpdatedAt      string
223	// Desc is the description rendered as Markdown (as a README: no raw
224	// HTML, filtered URLs), "" when it is empty.
225	Desc        template.HTML
226	Head        string
227	HeadShort   string
228	MergedShort string
229	MergedHref  string
230	MergeMode   string
231
232	Since     *SinceNav
233	SinceNote string
234
235	NFiles, Adds, Dels int
236	Files              []DiffFile
237	DiffView
238	Note      string
239	Invisible bool
240
241	// General are the comments that are not on a line, Loose those on a line
242	// that the diff shown does not have. With LooseInFiles (a window of files
243	// loaded later) the loose comments of a listed file go under the file
244	// instead (DiffFile.Loose).
245	General, Loose []CommentView
246	LooseInFiles   bool
247	NComments      int
248	NUnresolved    int
249	GeneralForm    *CommentForm
250	Verdicts       []VerdictView
251
252	Commits     []MRCommit
253	CommitsNote string
254	Modes       []string
255	ChecksBlock bool
256	// Checks are the CI checks of the head of the request, ChecksSum their
257	// state in the bar above the diff.
258	Checks    ChecksSection
259	ChecksSum ChecksSummary
260
261	Box *MergeBox
262	// Live is true when the page announces changes.
263	Live bool
264	// From is the first file of the window the page read the hunks of (?from=).
265	From int
266}
267
268// CompareCommit is a commit of a branch that the target lacks.
269type CompareCommit = CommitRow
270
271// ComparePage is a branch compared with the default branch.
272type ComparePage struct {
273	Source, Target string
274	SourceHref     string
275	TargetHref     string
276	Ahead          []CommitRow
277	AheadText      string
278	CLI            Clone
279	// Note says what the page could not tell.
280	Note string
281	// The diff is the one of the single commit the branch is ahead by.
282	NFiles, Adds, Dels int
283	Files              []DiffFile
284	DiffView
285	Invisible bool
286	HasDiff   bool
287	// Existing is the open merge request for this pair, when there is one.
288	Existing *Link
289	// Create is the form to open one; nil when the reader cannot or there is
290	// one already.
291	Create *CreateForm
292}
293
294// CreateForm is the POST that opens a merge request.
295type CreateForm struct {
296	Action, Source, Target, Title string
297}
298
299// mrBase is the URL of the merge request pages of a repository.
300func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
301
302func mrHref(repo string, n int64, sub ...string) string {
303	return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
304}
305
306// mrNumber reads the number of the path: a positive decimal number, written
307// without sign or leading zeros, else the page is not found.
308func mrNumber(r *http.Request) (int64, error) {
309	s := r.PathValue("n")
310	n, err := strconv.ParseInt(s, 10, 64)
311	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
312		return 0, errNotFound
313	}
314	return n, nil
315}
316
317// mrStateName is the state a page shows: "draft" for an open request whose
318// title marks it so.
319func mrStateName(m domain.MergeRequest) string {
320	if m.IsOpen() && m.IsDraft() {
321		return "draft"
322	}
323	return stateName(m.State)
324}
325
326func stateName(s domain.MRState) string {
327	switch s {
328	case domain.MRStateOpen:
329		return "open"
330	case domain.MRStateMerged:
331		return "merged"
332	case domain.MRStateClosed:
333		return "closed"
334	}
335	return "unknown"
336}
337
338func modeName(m domain.MergeMode) string {
339	switch m {
340	case domain.MergeModeFastForward:
341		return "fast-forward"
342	case domain.MergeModeSquash:
343		return "squash"
344	}
345	return ""
346}
347
348// mrRow converts a merge request of a listing.
349func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
350	row := MRRow{
351		Number: "#" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
352		Title: visibleText(m.Title.String()), State: mrStateName(m),
353		Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
354		Author:   s.who(m.Author),
355	}
356	ts := m.UpdatedAt
357	if ts.IsZero() {
358		ts = m.CreatedAt
359	}
360	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
361	return row
362}
363
364// mrList lists the merge requests: the open ones, then the merged and closed
365// ones, a page at a time. The decided ones are paged with the page token of
366// the use case in "page"; the open ones show on the first page only.
367func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
368	d, err := s.loadRepo(ctx, r.PathValue("repo"))
369	if err != nil {
370		return nil, 0, err
371	}
372	token := r.URL.Query().Get("page")
373	p := MRListPage{Default: visibleText(d.repo.DefaultBranch)}
374	p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
375	base := mrBase(d.name())
376
377	if token == "" {
378		tok := ""
379		for i := 0; i < maxOpenPages; i++ {
380			res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
381				State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok,
382			})
383			if err != nil {
384				return nil, 0, err
385			}
386			for _, m := range res.MergeRequests {
387				p.Open = append(p.Open, s.mrRow(d.name(), m))
388			}
389			if tok = res.NextPageToken; tok == "" {
390				break
391			}
392		}
393		if tok != "" {
394			p.OpenNote = "more open merge requests than are listed here."
395		}
396	}
397
398	tok := token
399	for i := 0; i < maxMRPages; i++ {
400		res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
401			PageSize: pageSize, PageToken: tok,
402		})
403		if err != nil {
404			return nil, 0, err
405		}
406		for _, m := range res.MergeRequests {
407			if m.State != domain.MRStateOpen {
408				p.Done = append(p.Done, s.mrRow(d.name(), m))
409			}
410		}
411		tok = res.NextPageToken
412		if tok == "" || len(p.Done) >= mrPerPage {
413			break
414		}
415	}
416	if tok != "" {
417		p.Older = base + "?page=" + url.QueryEscape(tok)
418	}
419	if token != "" {
420		p.Newest = base
421	}
422	switch {
423	case len(p.Open) > 0:
424		p.Open[0].Cursor = true
425	case len(p.Done) > 0:
426		p.Done[0].Cursor = true
427	}
428	for _, b := range d.branches {
429		if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches {
430			continue
431		}
432		p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)})
433	}
434
435	l := s.repoLayout(ctx, d, "merge-requests")
436	l.Title = "merge requests · " + d.name() + " · " + s.instance
437	l.Crumbs[0].Current = false
438	l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
439	if token == "" {
440		p.Live = true
441		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
442	}
443	l.HasRail = true
444	return MRList(l, p), http.StatusOK, nil
445}
446
447// mrShow shows a merge request: the whole diff against the target.
448func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
449	return s.mrPage(ctx, r, false)
450}
451
452// mrSince shows the diff since the head the reviewer last marked read.
453func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
454	return s.mrPage(ctx, r, true)
455}
456
457//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
458func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
459	n, err := mrNumber(r)
460	if err != nil {
461		return nil, 0, err
462	}
463	if since && !SignedIn(ctx) {
464		// The read position is the owner's: there is nothing to show others.
465		return nil, 0, errNotFound
466	}
467	d, err := s.loadRepo(ctx, r.PathValue("repo"))
468	if err != nil {
469		return nil, 0, err
470	}
471	from, err := parseFrom(r)
472	if err != nil {
473		return nil, 0, err
474	}
475	res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
476		SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
477	})
478	if err != nil {
479		return nil, 0, err
480	}
481	if from > 0 && from >= len(res.Diff.Files) {
482		return nil, 0, errNotFound
483	}
484	m := res.MergeRequest
485	now := s.cfg.Now()
486	signed := SignedIn(ctx)
487	open := m.State == domain.MRStateOpen
488	head := m.HeadID.String()
489	self := mrHref(d.name(), n)
490	if since {
491		self = mrHref(d.name(), n, "since")
492	}
493
494	p := MRPage{
495		Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
496		State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
497		Source: visibleText(m.Source), Target: visibleText(m.Target),
498		Author: s.who(m.Author),
499		Head:   head, HeadShort: short(head),
500		NFiles: res.Stats.FilesChanged,
501		Adds:   res.Stats.Additions,
502		Dels:   res.Stats.Deletions,
503		Files:  s.hl.files(ctx, res.Diff.Files),
504	}
505	contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
506	if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
507		// Relative links point into the source branch as it is now (its head
508		// commit); the heading ids carry their own prefix, apart from the
509		// ids of the diff.
510		h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
511		if err != nil {
512			return nil, 0, err
513		}
514		p.Desc = h
515	}
516	for _, v := range res.Verdicts {
517		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())})
518	}
519	p.Opened.Name = p.Author
520	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
521	if !m.CreatedAt.IsZero() {
522		p.Opened.Abs = absolute(m.CreatedAt)
523	}
524	p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
525	for _, b := range d.branches {
526		switch b.ShortName {
527		case m.Source:
528			p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
529		case m.Target:
530			p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
531		}
532	}
533	if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
534		p.MergedShort = short(m.MergedCommitID.String())
535		p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
536		p.MergeMode = modeName(m.MergeMode)
537	}
538	for _, mode := range d.repo.Settings.AllowedMergeModes {
539		if name := modeName(mode); name != "" {
540			p.Modes = append(p.Modes, name)
541		}
542	}
543	p.ChecksBlock = d.repo.Settings.FailingChecksBlock
544	if head != "" {
545		checks := s.checksFor(ctx, d.repo.Name, head)
546		p.Checks = checksSection(d.repo.Repo, checks)
547		p.ChecksSum = checksSummary(d.repo.Name, head, checks)
548		if !open {
549			p.Checks.Note = ""
550		}
551	}
552	p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
553	countNotes(p.Files, res.Comments)
554	if !res.DiffBaseID.IsZero() {
555		lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
556	}
557	p.From = from
558
559	// The commits of the request, newest first.
560	for _, c := range res.Commits {
561		row := commitRow(d.name(), c, now)
562		p.Commits = append(p.Commits, MRCommit{
563			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
564			Sig: row.Sig,
565		})
566	}
567	lastRead := res.LastReadID.String()
568	if signed && lastRead != "" {
569		// The commits are newest first: those before the last read head are new.
570		// A head that is not among them (the branch was rewritten) marks none.
571		for i, c := range res.Commits {
572			if c.ID.String() != lastRead {
573				continue
574			}
575			for j := 0; j < i; j++ {
576				p.Commits[j].New = true
577			}
578		}
579	}
580	if res.CommitsTruncated {
581		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
582	}
583
584	// Since the last read: the navigation, and what the page says about it.
585	if !signed {
586		lastRead = "" // the read position is the owner's
587	}
588	if signed && lastRead != "" {
589		p.Since = &SinceNav{
590			AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
591			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
592		}
593	}
594	if since {
595		switch {
596		case lastRead == "":
597			p.SinceNote = "nothing was marked read yet: this is the whole change."
598		case res.LastReadLost:
599			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
600		case len(p.Files) == 0:
601			p.SinceNote = "nothing changed since " + short(lastRead) + "."
602		default:
603			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
604		}
605	}
606	if p.Since != nil {
607		p.Since.Files = len(p.Files)
608	}
609
610	// Comments: under their line, else in the list below the diff. The forms
611	// are the owner's, on the whole diff of an open request.
612	canWrite := signed && open && !since
613	var on *lineRef
614	if canWrite {
615		on = parseOn(r.URL.Query().Get("on"))
616	}
617	s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
618	lines, invisible := diffLines(p.Files)
619	p.Invisible = invisible
620	p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
621	if signed && open {
622		p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
623		if m.IsDraft() {
624			p.Box.ReadyAction = mrHref(d.name(), n, "ready")
625		}
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))
651	l.HasRail = true
652	l.MR = true
653	return MergeRequest(l, p), http.StatusOK, nil
654}
655
656// hasBranch tells whether the repository has the branch.
657func (d *repoData) hasBranch(name string) bool {
658	for _, b := range d.branches {
659		if b.ShortName == name {
660			return true
661		}
662	}
663	return false
664}
665
666// mergeBox is what the owner can do with an open request.
667func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
668	b := &MergeBox{
669		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
670		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
671		CanMarkRead:     head != "" && head != lastRead,
672		CanDeleteBranch: source != d.repo.DefaultBranch,
673	}
674	ff, sq := false, false
675	for _, mode := range d.repo.Settings.AllowedMergeModes {
676		switch mode {
677		case domain.MergeModeFastForward:
678			ff = true
679			b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
680		case domain.MergeModeSquash:
681			sq = true
682			b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
683		}
684	}
685	if head != "" {
686		cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
687		b.CLI = Clone{ID: "cli-merge"}
688		if ff {
689			b.CLI.Lines = append(b.CLI.Lines, cmd)
690		}
691		if sq {
692			b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
693		}
694	}
695	return b
696}
697
698// shellWord quotes s for a shell unless it is made of safe characters, which
699// a repository name always is.
700func shellWord(s string) string {
701	for _, c := range []byte(s) {
702		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
703			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
704		}
705	}
706	return s
707}
708
709// lineRef designates a line of the diff in the query of the comment form:
710// "?on=new:12:path/to/file".
711type lineRef struct {
712	side  string
713	line  int
714	start int
715	path  string
716}
717
718func parseOn(s string) *lineRef {
719	side, rest, ok := strings.Cut(s, ":")
720	if !ok || (side != "new" && side != "old") {
721		return nil
722	}
723	num, path, ok := strings.Cut(rest, ":")
724	start := 0
725	if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
726		var e error
727		start, e = strconv.Atoi(a)
728		end, e2 := strconv.Atoi(b)
729		if e != nil || e2 != nil || start < 1 || start >= end {
730			return nil
731		}
732		num = b
733	}
734	line, err := strconv.Atoi(num)
735	if !ok || err != nil || line < 1 || path == "" {
736		return nil
737	}
738	return &lineRef{side: side, line: line, start: start, path: path}
739}
740
741// placeComments puts every comment under its line when the diff shown has it,
742// else in the list below the diff. The comments name lines of the diff against
743// the target, so a page that shows another diff (since the last read) lists
744// them all below, with their line. A reader who can write gets the line numbers
745// as links to the form of their line; on designates the line whose form shows.
746//
747//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
748func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
749	now := s.cfg.Now()
750	self := mrHref(repo, n)
751	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
752
753	type lineKey struct {
754		path string
755		side domain.DiffSide
756		line int
757	}
758	index := map[lineKey]*DiffLine{}
759	fileIDs := map[string]string{}
760	fileAt := map[string]int{}
761	for fi := range p.Files {
762		f := &p.Files[fi]
763		fileIDs[f.Key] = f.ID
764		fileAt[f.Key] = fi
765		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
766		// The form of a line shows on the page of the window of its file.
767		on := self + "?on="
768		if w := windowStart(fi); w > 0 {
769			on = self + "?from=" + itoa(w) + "&on="
770		}
771		for hi := range f.Hunks {
772			for li := range f.Hunks[hi].Lines {
773				ln := &f.Hunks[hi].Lines[li]
774				if ln.oldN > 0 && ln.Kind != "add" {
775					index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
776					if linkable {
777						ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
778					}
779				}
780				if ln.newN > 0 && ln.Kind != "del" {
781					index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
782					if linkable {
783						ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
784					}
785				}
786			}
787		}
788	}
789	find := func(path string, side domain.DiffSide, line int) *DiffLine {
790		if !inline {
791			return nil
792		}
793		return index[lineKey{path, side, line}]
794	}
795
796	exists := noteIDs(comments)
797	children := map[int64][]domain.Comment{}
798	for _, c := range comments {
799		if c.ReplyTo != 0 && !c.Deleted {
800			children[c.ReplyTo] = append(children[c.ReplyTo], c)
801		}
802		if !c.Deleted {
803			p.NComments++
804		}
805		if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
806			p.NUnresolved++
807		}
808	}
809	var view func(domain.Comment, bool) CommentView
810	view = func(c domain.Comment, replyable bool) CommentView {
811		cid := strconv.FormatInt(c.ID, 10)
812		body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
813		if err != nil {
814			body = ""
815		}
816		bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
817		if err != nil {
818			bodySplit = ""
819		}
820		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}
821		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
822		if !c.CreatedAt.IsZero() {
823			v.Abs = absolute(c.CreatedAt)
824		}
825		if c.StartLine > 0 {
826			v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
827		}
828		if canWrite && !c.Deleted {
829			from := windowStart(fileAt[c.Path])
830			root := cid
831			if c.ReplyTo != 0 {
832				root = strconv.FormatInt(c.ReplyTo, 10)
833			}
834			if replyable {
835				v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836			}
837			if c.ReplyTo == 0 {
838				v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
839			}
840			if c.Author == p.NoteAuthor {
841				v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
842				v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
843			}
844		}
845		for _, reply := range children[c.ID] {
846			v.Replies = append(v.Replies, view(reply, replyable))
847		}
848		return v
849	}
850	for _, c := range comments {
851		if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
852			continue
853		}
854		cid := strconv.FormatInt(c.ID, 10)
855		v := view(c, canWrite && !c.Deleted)
856		general := c.Path == ""
857		fi, listed := fileAt[c.Path]
858		if !general && listed && p.Files[fi].Lazy {
859			// Its file comes with its window, and the comment with it.
860			continue
861		}
862		var ln *DiffLine
863		if !general {
864			ln = find(c.Path, c.Side, c.Line)
865			if ln == nil {
866				side := "new"
867				if c.Side == domain.SideOld {
868					side = "old"
869				}
870				v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
871			}
872		}
873		if canWrite && !c.Deleted {
874			v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
875			if !general && listed {
876				v.Resolve.From = windowStart(fi)
877			}
878			if c.Resolved {
879				v.Resolve.Label = "Unresolve"
880			}
881		}
882		switch {
883		case ln != nil:
884			ln.Notes = append(ln.Notes, v)
885		case general:
886			p.General = append(p.General, v)
887		case p.LooseInFiles && listed:
888			p.Files[fi].Loose = append(p.Files[fi].Loose, v)
889		default:
890			p.Loose = append(p.Loose, v)
891		}
892	}
893
894	if on != nil {
895		side := domain.SideNew
896		if on.side == "old" {
897			side = domain.SideOld
898		}
899		start := on.start
900		if start == 0 {
901			start = on.line
902		}
903		for key, ln := range index {
904			if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
905				ln.Selected = true
906			}
907		}
908		if ln := find(on.path, side, on.line); ln != nil {
909			ln.Form = &CommentForm{
910				StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
911				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
912				Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
913				From: windowStart(fileAt[on.path]),
914			}
915		}
916	}
917}
918
919// userText renders the parts of a plain text: the text escaped, the http(s)
920// addresses as links. Its white space is kept by the style of the container.
921func userText(parts []TextPart) templ.Component {
922	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
923		var b strings.Builder
924		for _, part := range parts {
925			if part.Href != "" {
926				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
927			} else {
928				b.WriteString(html.EscapeString(part.Text))
929			}
930		}
931		_, err := io.WriteString(w, b.String())
932		return err
933	})
934}
935
936func noteIDs(comments []domain.Comment) func(int64) bool {
937	ids := make(map[int64]bool, len(comments))
938	for _, c := range comments {
939		ids[c.ID] = true
940	}
941	return func(n int64) bool { return ids[n] }
942}
943func optionalLine(n int) string {
944	if n == 0 {
945		return ""
946	}
947	return itoa(n)
948}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close