detached aa7340c

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

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 46

↑↓ moveenter openesc close