v0.31.0 d5e0505

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

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