detached 973e682

mr.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"html"
9	"html/template"
10	"io"
11	"net/http"
12	"net/url"
13	"strconv"
14	"strings"
15
16	"github.com/a-h/templ"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// The merge request pages (ADR 0017). They read through MergeRequestService
23// and BrowseService as the visitor of the request: anonymous sees the merge
24// requests of public repositories, the signed-in owner those of every
25// repository, and only the owner gets the forms (mr_post.go handles them).
26//
27// Routes:
28//
29//	GET  /<repo>/merge-requests              the list, open ones first
30//	GET  /<repo>/merge-requests/<n>          the request with its diff
31//	GET  /<repo>/merge-requests/<n>/since    the diff since the last read
32//	GET  /<repo>/compare/<branch>            a branch against the default branch
33//	GET  /<repo>/merge-requests/<n>/files    a window of files of the diff
34//
35// The page lists every changed file and reads the hunks of the first window
36// of files only (mrWindow); the files after it are placeholders that the
37// browser fills in, a window per request, as they come near the screen
38// (/files?from=&base=&head=). Without scripts each placeholder links to the
39// page of its window (?from=), which shows the hunks of that window.
40//
41// Reading marks nothing as read. MarkRead stores state, and a GET must stay
42// safe: a prefetcher, a link preview or a reload of the owner's tab would move
43// the read position, and the "since your last read" diff would lose what the
44// owner never saw. So the owner marks a head read with a POST button
45// ("[mark read]", /<n>/read), which carries the head the page showed; the
46// button only shows when that head is not the one already read. The since page
47// is the owner's alone (the position is the owner's), and answers 404 to
48// anyone else.
49//
50// Every page here is rendered through page(), so for the owner it is
51// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
52// forms and the CLI lines exist only in the owner's copy.
53
54const (
55	// mrWindow is how many files of a merge request one read gets the hunks
56	// of: the page shows the first window and loads the others.
57	mrWindow = 25
58	// mrPerPage is how many decided merge requests a page of the list shows,
59	// at least; pages of the use case are read whole.
60	mrPerPage = 50
61	// maxMRPages bounds the pages of the use case one list page reads.
62	maxMRPages = 20
63	// maxOpenPages bounds the pages read for the open merge requests, which
64	// the first list page shows in full.
65	maxOpenPages = 5
66	// railBranches is how many branches the list offers to compare.
67	railBranches = 15
68)
69
70// MRRow is one merge request in the list.
71type MRRow struct {
72	Number   string
73	Href     string
74	Title    string
75	Branches string
76	Author   string
77	State    string
78	When     string
79	WhenAt   string
80	Cursor   bool
81}
82
83// BranchLink is a branch the list offers to open a merge request for.
84type BranchLink struct{ Name, Href string }
85
86// MRListPage is the list of the merge requests of a repository.
87type MRListPage struct {
88	Open, Done []MRRow
89	// OpenNote says the open list was cut.
90	OpenNote string
91	// Older and Newest are the paging links of the decided requests.
92	Older, Newest string
93	Branches      []BranchLink
94	Default       string
95	CLI           Clone
96	// Live is true when the page announces changes.
97	Live bool
98}
99
100// CommentView is a comment of a merge request, ready to show.
101type CommentView struct {
102	ID              string
103	Author          string
104	When            string
105	WhenAt          string
106	Abs             string
107	Body, BodySplit template.HTML
108	// Parts is a plain-text body (an issue comment); Body is the Markdown of
109	// a merge request note.
110	Parts                 []TextPart
111	Replies               []CommentView
112	Edited, Deleted       bool
113	Verdict, Range, Quote string
114	Edit                  *EditForm
115	Delete                *DeleteForm
116	Reply                 *CommentForm
117	Resolved              bool
118	// Outdated is true when the head moved since the comment was written, so
119	// its line may have moved too.
120	Outdated bool
121	// Where names the line of a comment that is not under a line of the diff
122	// shown ("path:12 (new)").
123	Where string
124	// Resolve is the form that resolves or unresolves it; nil when the reader
125	// cannot.
126	Resolve *ResolveForm
127}
128
129type EditForm struct {
130	Action, Text string
131	From         int
132}
133type DeleteForm struct {
134	Action string
135	From   int
136}
137type VerdictView struct {
138	Author, Label, Href, Short string
139	Stale                      bool
140}
141
142// ResolveForm is the POST that changes the resolved state of a comment.
143type ResolveForm struct {
144	Action string
145	// Value is the state the form sets.
146	Value bool
147	Label string
148	// From is the window of the page the comment is on (CommentForm.From).
149	From int
150}
151
152// CommentForm is the POST that adds a comment.
153type CommentForm struct {
154	ID     string
155	Action string
156	Title  string
157	// Path, Line and Side designate the line; empty for a general comment.
158	Path, Line, Side   string
159	StartLine, ReplyTo string
160	// Plain is a form of an issue comment: plain text, no verdict.
161	Plain  bool
162	Cancel string
163	Focus  bool
164	// From is the window of the page the form is on (see mrWindow), sent
165	// back so that the answer returns to it; 0 for the first.
166	From int
167}
168
169// MRCommit is a commit of the request.
170type MRCommit struct {
171	Short, Href, Subject, Author, When, WhenAt string
172	// New is true for a commit pushed after the head the reviewer last read.
173	New bool
174	// Sig is the signature badge, nil for an unsigned or unchecked commit.
175	Sig *SignatureBadge
176}
177
178// MergeBox is what the owner does with an open request.
179type MergeBox struct {
180	MergeAction string
181	CloseAction string
182	// ReadyAction takes a draft out of draft; empty when it is not one: a
183	// draft shows it in place of the merge form.
184	ReadyAction string
185	ReadAction  string
186	Head        string
187	Modes       []ModeOption
188	// HeadGone is true when the source branch no longer exists: there is
189	// nothing to merge.
190	HeadGone bool
191	CLI      Clone
192	// CanMarkRead is true when the head differs from the one last read.
193	CanMarkRead bool
194	// CanDeleteBranch offers to delete the source after the merge: it is not
195	// the default branch. The server checks again, with the open requests.
196	CanDeleteBranch bool
197	// Mergeability is the live badge of the box and what the modes depend on.
198	Mergeability MergeabilityView
199	// Signed is true when a mode that creates a commit (squash, merge commit)
200	// is offered; Fingerprint is then the one of the key that signs it, empty
201	// when the server does not tell.
202	Signed      bool
203	Fingerprint string
204	// Error is why the last merge was refused, shown in the box.
205	Error string
206}
207
208// ModeOption is a merge mode the repository allows. Disabled says that the
209// mode is not possible for the current state of the request, Reason why.
210type ModeOption struct {
211	Value, Label string
212	Disabled     bool
213	Reason       string
214}
215
216// MergeabilityView is the mergeability of an open request, ready to show: the
217// badge, and what the merge modes depend on (the flags are what the controls
218// of the box read, live).
219type MergeabilityView struct {
220	State, Label, Detail string
221	Paths                []string
222	// PathsNote says that more paths conflict than are listed.
223	PathsNote string
224	// FFOk and MergeOk say whether fast-forward, squash and merge commit are
225	// possible; the reasons say why not.
226	FFOk, MergeOk         bool
227	FFReason, MergeReason string
228}
229
230// SinceNav switches between the whole diff and the diff since the last read.
231type SinceNav struct {
232	AllHref, SinceHref string
233	Files              int
234	ReadShort          string
235	HeadShort          string
236	Current            bool
237}
238
239// MRPage is a merge request with its diff and comments.
240type MRPage struct {
241	Number         string
242	Title          string
243	State          string
244	Open           bool
245	Signed         bool
246	NoteAuthor     string
247	Source, Target string
248	SourceHref     string
249	TargetHref     string
250	Author         string
251	Opened         Person
252	Updated        string
253	UpdatedAt      string
254	// Desc is the description rendered as Markdown (as a README: no raw
255	// HTML, filtered URLs), "" when it is empty.
256	Desc        template.HTML
257	Head        string
258	HeadShort   string
259	MergedShort string
260	MergedHref  string
261	MergeMode   string
262	// Deleting is the source branch that a merge just asked to delete: the
263	// deletion follows the answer.
264	Deleting string
265	// MergeError is why a merge was refused when the page has no merge box
266	// to show it in (the request is not open any more).
267	MergeError string
268	// Canonical is the address of the request, set when the page answers a
269	// refused action from the address of that action: the script puts it in
270	// the address bar, so that a reload does not post again.
271	Canonical string
272
273	Since     *SinceNav
274	SinceNote string
275
276	NFiles, Adds, Dels int
277	Files              []DiffFile
278	DiffView
279	Note      string
280	Invisible bool
281
282	// General are the comments that are not on a line, Loose those on a line
283	// that the diff shown does not have. With LooseInFiles (a window of files
284	// loaded later) the loose comments of a listed file go under the file
285	// instead (DiffFile.Loose).
286	General, Loose []CommentView
287	LooseInFiles   bool
288	NComments      int
289	NUnresolved    int
290	GeneralForm    *CommentForm
291	Verdicts       []VerdictView
292
293	Commits     []MRCommit
294	CommitsNote 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	// A page without scripts: ?file=i reads that collapsed file in full.
573	// Without a pinned base, or when the pair moved since the first read, the
574	// page is shown unspliced.
575	if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() && !res.DiffBaseID.IsZero() {
576		one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
577			SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
578			Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
579		})
580		if err != nil && !errors.Is(err, domain.ErrConflict) {
581			return nil, 0, err
582		}
583		if err == nil && i < len(one.Diff.Files) {
584			res.Diff.Files[i] = one.Diff.Files[i]
585		}
586	}
587	m := res.MergeRequest
588	signed := SignedIn(ctx)
589	open := m.State == domain.MRStateOpen
590	head := m.HeadID.String()
591	self := mrHref(d.name(), n)
592	if since {
593		self = mrHref(d.name(), n, "since")
594	}
595
596	p := MRPage{
597		Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
598		State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
599		Source: visibleText(m.Source), Target: visibleText(m.Target),
600		Author: s.who(m.Author),
601		Head:   head, HeadShort: short(head),
602		NFiles: res.Stats.FilesChanged,
603		Adds:   res.Stats.Additions,
604		Dels:   res.Stats.Deletions,
605		Files:  s.hl.files(ctx, res.Diff.Files),
606	}
607	contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
608	src := ""
609	if !res.DiffBaseID.IsZero() {
610		src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
611	}
612	collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
613	if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
614		return nil, 0, err
615	}
616
617	p.Note = diffNote(res.Diff)
618	countNotes(p.Files, res.Comments)
619	if !res.DiffBaseID.IsZero() {
620		lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
621	}
622	p.From = from
623
624	s.mrReadPresentation(&p, d.name(), n, res, since, signed)
625
626	// Comments: under their line, else in the list below the diff. The forms
627	// are the owner's, on the whole diff of an open request.
628	canWrite := signed && open && !since
629	var on *lineRef
630	if canWrite {
631		on = parseOn(r.URL.Query().Get("on"))
632	}
633	s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
634	lines, invisible := diffLines(p.Files)
635	p.Invisible = invisible
636	p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
637	if signed && open {
638		p.Box = s.mrBox(ctx, d, res)
639	}
640
641	if mergeErrorOf(ctx) != "" {
642		p.Canonical = self
643	}
644	if p.Box == nil {
645		p.MergeError = mergeErrorOf(ctx) // the request is not open any more
646	}
647	if canWrite {
648		p.GeneralForm = &CommentForm{
649			ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
650		}
651	}
652
653	l := s.repoLayout(ctx, d, "merge-requests")
654	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
655	if head != "" {
656		pinRef(&l, target{rev: head, name: m.Source})
657		if !d.hasBranch(m.Source) {
658			pinRef(&l, target{rev: head, name: short(head), detached: true})
659		}
660	}
661	l.Crumbs[0].Current = false
662	l.Crumbs = append(l.Crumbs,
663		Crumb{Label: "merge requests", Href: mrBase(d.name())},
664		Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
665	)
666	if since {
667		l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
668	}
669	p.Live = true
670	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))
671	l.HasRail = true
672	l.MR, l.Lazy = true, true
673	return MergeRequest(l, p), http.StatusOK, nil
674}
675
676func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
677	m := res.MergeRequest
678	box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
679	box.Error = mergeErrorOf(ctx)
680	if m.IsDraft() {
681		box.ReadyAction = mrHref(d.name(), m.Number, "ready")
682	}
683	return box
684}
685
686// mrFacts formats the description, metadata, branch links and checks.
687func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
688	m := res.MergeRequest
689	head := m.HeadID.String()
690	signed := SignedIn(ctx)
691	open := m.State == domain.MRStateOpen
692	now := s.cfg.Now()
693	if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
694		// Relative links point into the source branch as it is now (its head
695		// commit); the heading ids carry their own prefix, apart from the
696		// ids of the diff.
697		h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
698		if err != nil {
699			return err
700		}
701		p.Desc = h
702	}
703	for _, v := range res.Verdicts {
704		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())})
705	}
706	p.Opened.Name = p.Author
707	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
708	if !m.CreatedAt.IsZero() {
709		p.Opened.Abs = absolute(m.CreatedAt)
710	}
711	p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
712	for _, b := range d.branches {
713		switch b.ShortName {
714		case m.Source:
715			p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
716		case m.Target:
717			p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
718		}
719	}
720	if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
721		p.MergedShort = short(m.MergedCommitID.String())
722		p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
723		p.MergeMode = modeName(m.MergeMode)
724	}
725	if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
726		p.Deleting = visibleText(m.Source)
727	}
728	for _, mode := range d.repo.Settings.AllowedMergeModes {
729		if name := modeName(mode); name != "" {
730			p.Modes = append(p.Modes, name)
731		}
732	}
733	p.ChecksBlock = d.repo.Settings.FailingChecksBlock
734	if head != "" {
735		checks := s.checksFor(ctx, d.repo.Name, head)
736		p.Checks = checksSection(d.repo.Repo, checks)
737		p.ChecksSum = checksSummary(d.repo.Name, head, checks)
738		if !open {
739			p.Checks.Note = ""
740		}
741	}
742	return nil
743}
744
745// mrReadPresentation maps usecase read tracking to commit rows and navigation.
746func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
747	now := s.cfg.Now()
748	head := res.MergeRequest.HeadID.String()
749	// The commits of the request, newest first.
750	for i, c := range res.Commits {
751		row := commitRow(name, c, now)
752		p.Commits = append(p.Commits, MRCommit{
753			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
754			Sig: row.Sig, New: signed && i < res.NewCommits,
755		})
756	}
757	lastRead := res.LastReadID.String()
758	if res.CommitsTruncated {
759		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
760	}
761
762	// Since the last read: the navigation, and what the page says about it.
763	if signed && lastRead != "" {
764		p.Since = &SinceNav{
765			AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
766			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
767		}
768	}
769	if since {
770		switch {
771		case lastRead == "":
772			p.SinceNote = "nothing was marked read yet: this is the whole change."
773		case res.LastReadLost:
774			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
775		case len(p.Files) == 0:
776			p.SinceNote = "nothing changed since " + short(lastRead) + "."
777		default:
778			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
779		}
780	}
781	if p.Since != nil {
782		p.Since.Files = len(p.Files)
783	}
784
785}
786
787// hasBranch tells whether the repository has the branch.
788func (d *repoData) hasBranch(name string) bool {
789	for _, b := range d.branches {
790		if b.ShortName == name {
791			return true
792		}
793	}
794	return false
795}
796
797// mergeBox is what the owner can do with an open request.
798func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
799	b := &MergeBox{
800		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
801		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
802		CanMarkRead:     canMarkRead,
803		CanDeleteBranch: source != d.repo.DefaultBranch,
804		Mergeability:    mg,
805	}
806	var lines []string
807	cmd := ""
808	if head != "" {
809		cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
810	}
811	for _, mode := range d.repo.Settings.AllowedMergeModes {
812		switch mode {
813		case domain.MergeModeFastForward:
814			b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
815			lines = append(lines, cmd)
816		case domain.MergeModeSquash:
817			b.Signed = true
818			b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
819			lines = append(lines, cmd+" --mode squash")
820		case domain.MergeModeMerge:
821			b.Signed = true
822			b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
823			lines = append(lines, cmd+" --mode merge")
824		}
825	}
826	if b.Signed && s.cfg.SigningKey != nil {
827		b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
828	}
829	if head != "" {
830		b.CLI = Clone{ID: "cli-merge", Lines: lines}
831	}
832	return b
833}
834
835func reasonIf(cond bool, reason string) string {
836	if cond {
837		return reason
838	}
839	return ""
840}
841
842// firstModeOK is the index of the first mode that is possible, -1 for none.
843func (b *MergeBox) firstModeOK() int {
844	for i, m := range b.Modes {
845		if !m.Disabled {
846			return i
847		}
848	}
849	return -1
850}
851
852// Checked is the mode the form has checked: the first that is possible, else
853// the first (it is disabled, and a live update may enable it).
854func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
855
856// CanMerge tells whether some mode is possible: the button is enabled.
857func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
858
859// MergeTitle is why the button is disabled, empty when it is not.
860func (b *MergeBox) MergeTitle() string {
861	if b.CanMerge() {
862		return ""
863	}
864	if len(b.Modes) == 0 {
865		return "the repository allows no merge mode"
866	}
867	return b.Modes[0].Reason
868}
869
870// shellWord quotes s for a shell unless it is made of safe characters, which
871// a repository name always is.
872func shellWord(s string) string {
873	for _, c := range []byte(s) {
874		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
875			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
876		}
877	}
878	return s
879}
880
881// lineRef designates a line of the diff in the query of the comment form:
882// "?on=new:12:path/to/file".
883type lineRef struct {
884	side  string
885	line  int
886	start int
887	path  string
888}
889
890func parseOn(s string) *lineRef {
891	side, rest, ok := strings.Cut(s, ":")
892	if !ok || (side != "new" && side != "old") {
893		return nil
894	}
895	num, path, ok := strings.Cut(rest, ":")
896	start := 0
897	if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
898		var e error
899		start, e = strconv.Atoi(a)
900		end, e2 := strconv.Atoi(b)
901		if e != nil || e2 != nil || start < 1 || start >= end {
902			return nil
903		}
904		num = b
905	}
906	line, err := strconv.Atoi(num)
907	if !ok || err != nil || line < 1 || path == "" {
908		return nil
909	}
910	return &lineRef{side: side, line: line, start: start, path: path}
911}
912
913// placeComments puts every comment under its line when the diff shown has it,
914// else in the list below the diff. The comments name lines of the diff against
915// the target, so a page that shows another diff (since the last read) lists
916// them all below, with their line. A reader who can write gets the line numbers
917// as links to the form of their line; on designates the line whose form shows.
918//
919//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
920func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
921	now := s.cfg.Now()
922	self := mrHref(repo, n)
923	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
924
925	type lineKey struct {
926		path string
927		side domain.DiffSide
928		line int
929	}
930	index := map[lineKey]*DiffLine{}
931	fileIDs := map[string]string{}
932	fileAt := map[string]int{}
933	for fi := range p.Files {
934		f := &p.Files[fi]
935		fileIDs[f.Key] = f.ID
936		fileAt[f.Key] = fi
937		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
938		// The form of a line shows on the page of the window of its file.
939		on := self + "?on="
940		if w := windowStart(fi); w > 0 {
941			on = self + "?from=" + itoa(w) + "&on="
942		}
943		for hi := range f.Hunks {
944			for li := range f.Hunks[hi].Lines {
945				ln := &f.Hunks[hi].Lines[li]
946				if ln.oldN > 0 && ln.Kind != "add" {
947					index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
948					if linkable {
949						ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
950					}
951				}
952				if ln.newN > 0 && ln.Kind != "del" {
953					index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
954					if linkable {
955						ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
956					}
957				}
958			}
959		}
960	}
961	find := func(path string, side domain.DiffSide, line int) *DiffLine {
962		if !inline {
963			return nil
964		}
965		return index[lineKey{path, side, line}]
966	}
967
968	exists := noteIDs(comments)
969	children := map[int64][]domain.Comment{}
970	for _, c := range comments {
971		if c.ReplyTo != 0 && !c.Deleted {
972			children[c.ReplyTo] = append(children[c.ReplyTo], c)
973		}
974		if !c.Deleted {
975			p.NComments++
976		}
977		if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
978			p.NUnresolved++
979		}
980	}
981	var view func(domain.Comment, bool) CommentView
982	view = func(c domain.Comment, replyable bool) CommentView {
983		cid := strconv.FormatInt(c.ID, 10)
984		body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
985		if err != nil {
986			body = ""
987		}
988		bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
989		if err != nil {
990			bodySplit = ""
991		}
992		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}
993		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
994		if !c.CreatedAt.IsZero() {
995			v.Abs = absolute(c.CreatedAt)
996		}
997		if c.StartLine > 0 {
998			v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
999		}
1000		if canWrite && !c.Deleted {
1001			from := windowStart(fileAt[c.Path])
1002			root := cid
1003			if c.ReplyTo != 0 {
1004				root = strconv.FormatInt(c.ReplyTo, 10)
1005			}
1006			if replyable {
1007				v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
1008			}
1009			if c.ReplyTo == 0 {
1010				v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
1011			}
1012			if c.Author == p.NoteAuthor {
1013				v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
1014				v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
1015			}
1016		}
1017		for _, reply := range children[c.ID] {
1018			v.Replies = append(v.Replies, view(reply, replyable))
1019		}
1020		return v
1021	}
1022	for _, c := range comments {
1023		if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
1024			continue
1025		}
1026		cid := strconv.FormatInt(c.ID, 10)
1027		v := view(c, canWrite && !c.Deleted)
1028		general := c.Path == ""
1029		fi, listed := fileAt[c.Path]
1030		if !general && listed && p.Files[fi].Lazy {
1031			// Its file comes with its window, and the comment with it.
1032			continue
1033		}
1034		var ln *DiffLine
1035		if !general {
1036			ln = find(c.Path, c.Side, c.Line)
1037			if ln == nil {
1038				side := "new"
1039				if c.Side == domain.SideOld {
1040					side = "old"
1041				}
1042				v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
1043			}
1044		}
1045		if canWrite && !c.Deleted {
1046			v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
1047			if !general && listed {
1048				v.Resolve.From = windowStart(fi)
1049			}
1050			if c.Resolved {
1051				v.Resolve.Label = "Unresolve"
1052			}
1053		}
1054		switch {
1055		case ln != nil:
1056			ln.Notes = append(ln.Notes, v)
1057		case general:
1058			p.General = append(p.General, v)
1059		case p.LooseInFiles && listed:
1060			p.Files[fi].Loose = append(p.Files[fi].Loose, v)
1061		default:
1062			p.Loose = append(p.Loose, v)
1063		}
1064	}
1065
1066	if on != nil {
1067		side := domain.SideNew
1068		if on.side == "old" {
1069			side = domain.SideOld
1070		}
1071		start := on.start
1072		if start == 0 {
1073			start = on.line
1074		}
1075		for key, ln := range index {
1076			if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
1077				ln.Selected = true
1078			}
1079		}
1080		// The title names the whole range a shift + click selected: a.go:21-149.
1081		lines := itoa(on.line)
1082		if start < on.line {
1083			lines = itoa(start) + "-" + lines
1084		}
1085		if ln := find(on.path, side, on.line); ln != nil {
1086			ln.Form = &CommentForm{
1087				StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
1088				Title:  "comment on " + visibleText(on.path) + ":" + lines + " (" + on.side + ")",
1089				Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
1090				From: windowStart(fileAt[on.path]),
1091			}
1092		}
1093	}
1094}
1095
1096// userText renders the parts of a plain text: the text escaped, the http(s)
1097// addresses as links. Its white space is kept by the style of the container.
1098func userText(parts []TextPart) templ.Component {
1099	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
1100		var b strings.Builder
1101		for _, part := range parts {
1102			if part.Href != "" {
1103				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
1104			} else {
1105				b.WriteString(html.EscapeString(part.Text))
1106			}
1107		}
1108		_, err := io.WriteString(w, b.String())
1109		return err
1110	})
1111}
1112
1113func noteIDs(comments []domain.Comment) func(int64) bool {
1114	ids := make(map[int64]bool, len(comments))
1115	for _, c := range comments {
1116		ids[c.ID] = true
1117	}
1118	return func(n int64) bool { return ids[n] }
1119}
1120func optionalLine(n int) string {
1121	if n == 0 {
1122		return ""
1123	}
1124	return itoa(n)
1125}

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 2

Tags 45

↑↓ moveenter openesc close