detached 83e495f

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

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