detached 388d3be

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

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