v0.28.0 fca9155

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

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