detached cab7cdc

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

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