v0.35.1 2aca1df

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

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