detached cab7cdc

mergerequest.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"fmt"
7	"slices"
8	"strings"
9	"time"
10)
11
12// MergeMode is how a merge request is merged (ADR 0017).
13type MergeMode string
14
15// Merge modes.
16const (
17	// MergeModeFastForward moves the target to the head; no commit is made.
18	MergeModeFastForward MergeMode = "fast-forward"
19	// MergeModeSquash makes one new signed commit with the tree of the head.
20	MergeModeSquash MergeMode = "squash"
21	// MergeModeMerge makes one new signed commit with two parents: the target
22	// tip and the head.
23	MergeModeMerge MergeMode = "merge"
24)
25
26// MergeModes returns the modes in their canonical order.
27func MergeModes() []MergeMode {
28	return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29}
30
31// ParseMergeMode parses "fast-forward", "squash" or "merge".
32func ParseMergeMode(s string) (MergeMode, error) {
33	m := MergeMode(s)
34	if slices.Contains(MergeModes(), m) {
35		return m, nil
36	}
37	return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
38}
39
40// String returns the mode as text.
41func (m MergeMode) String() string { return string(m) }
42
43// RepoSettings are the settings of a repository that govern its merge
44// requests.
45type RepoSettings struct {
46	// AllowedMergeModes is never empty, in canonical order, without
47	// duplicates.
48	AllowedMergeModes []MergeMode
49	// FailingChecksBlock says that a failing check prevents the merge.
50	FailingChecksBlock bool
51	// Issues says who may write issues; the empty mode means IssuesOff.
52	Issues IssuesMode
53}
54
55// DefaultRepoSettings are the settings of a new repository: every mode, and
56// failing checks block the merge.
57func DefaultRepoSettings() RepoSettings {
58	return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
59}
60
61// IsZero reports whether s holds no mode, which only the zero value does.
62func (s RepoSettings) IsZero() bool { return len(s.AllowedMergeModes) == 0 }
63
64// Allows reports whether the mode may be used.
65func (s RepoSettings) Allows(m MergeMode) bool { return slices.Contains(s.AllowedMergeModes, m) }
66
67// NormalizeMergeModes validates a list of modes for the settings: it must not
68// be empty or hold an unknown mode. The result is in canonical order, without
69// duplicates.
70func NormalizeMergeModes(modes []MergeMode) ([]MergeMode, error) {
71	if len(modes) == 0 {
72		return nil, fmt.Errorf("%w: at least one merge mode is required", ErrInvalid)
73	}
74	for _, m := range modes {
75		if _, err := ParseMergeMode(m.String()); err != nil {
76			return nil, err
77		}
78	}
79	var out []MergeMode
80	for _, m := range MergeModes() {
81		if slices.Contains(modes, m) {
82			out = append(out, m)
83		}
84	}
85	return out, nil
86}
87
88// MRState is where a merge request is in its life.
89type MRState string
90
91// Merge request states.
92const (
93	// MRStateOpen awaits review.
94	MRStateOpen MRState = "open"
95	// MRStateMerged is final: the branch was merged.
96	MRStateMerged MRState = "merged"
97	// MRStateClosed is final: the request was closed without merging.
98	MRStateClosed MRState = "closed"
99)
100
101// DiffSide is the side of a diff a comment is attached to.
102type DiffSide string
103
104// Diff sides. The empty side marks a general comment.
105const (
106	// SideNone is the side of a general comment.
107	SideNone DiffSide = ""
108	// SideOld counts lines of the old file.
109	SideOld DiffSide = "old"
110	// SideNew counts lines of the new file.
111	SideNew DiffSide = "new"
112)
113
114// MergeRequest asks to merge one branch into another of the same repository.
115type MergeRequest struct {
116	Repo   RepoName
117	Number int64
118	Title  MRTitle
119	// Description may be empty.
120	Description PlainText
121	// Source and Target are short branch names.
122	Source, Target string
123	State          MRState
124	// Author is the label of the token that opened it, "root" or "owner".
125	Author    string
126	CreatedAt time.Time
127	UpdatedAt time.Time
128	// HeadID is the head of the source branch: stored when the request is
129	// merged or closed, and filled from Git at every read while it is open
130	// (zero when the branch is gone).
131	HeadID ObjectID
132	// BaseID is the merge base of the target and the head when the request
133	// was merged or closed; zero while it is open.
134	BaseID ObjectID
135	// MergedCommitID is the tip of the target after the merge; zero unless
136	// merged.
137	MergedCommitID ObjectID
138	// MergeMode is the mode used; empty unless merged.
139	MergeMode MergeMode
140	// CommentSeq is the id of the last comment of the request.
141	CommentSeq int64
142	// Version is set by the store on read: Update fails with ErrConflict when
143	// the stored request changed since. Opaque to everyone else.
144	Version uint64
145}
146
147// draftPrefixes mark a draft at the start of a title, compared without regard
148// to case, as Forgejo and Gitea do.
149var draftPrefixes = []string{"wip:", "[wip]"}
150
151// IsDraft reports whether the title marks the request as a draft: it starts
152// with "WIP:" or "[WIP]". A draft cannot be merged.
153func (mr MergeRequest) IsDraft() bool {
154	_, ok := draftRest(mr.Title.String())
155	return ok
156}
157
158// ReadyTitle is the title without its draft prefixes (all of them, so that
159// "WIP: [WIP] x" is no longer a draft), and false when the title has none or
160// nothing would be left of it.
161func ReadyTitle(t MRTitle) (MRTitle, bool) {
162	rest, ok := draftRest(t.String())
163	if !ok {
164		return MRTitle{}, false
165	}
166	for {
167		next, more := draftRest(rest)
168		if !more {
169			break
170		}
171		rest = next
172	}
173	ready, err := ParseMRTitle(rest)
174	if err != nil {
175		return MRTitle{}, false
176	}
177	return ready, true
178}
179
180// draftRest is the title after its draft prefix and the spaces that follow.
181func draftRest(title string) (string, bool) {
182	t := strings.TrimLeft(title, " ")
183	for _, p := range draftPrefixes {
184		if len(t) >= len(p) && strings.EqualFold(t[:len(p)], p) {
185			return strings.TrimLeft(t[len(p):], " "), true
186		}
187	}
188	return "", false
189}
190
191// IsOpen reports whether the request awaits review.
192func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
193
194// Comment is a note on a merge request, general or on a line of its diff.
195type Comment struct {
196	ReplyTo   int64
197	StartLine int
198	Verdict   Verdict
199	EditedAt  time.Time
200	Deleted   bool
201	// ID is unique within the merge request, in order of creation.
202	ID     int64
203	Author string
204	// Path, Line and Side are empty for a general comment.
205	Path string
206	Line int
207	Side DiffSide
208	// CommitID is the head of the request when the comment was written; zero
209	// when the source branch was gone.
210	CommitID  ObjectID
211	Text      PlainText
212	Resolved  bool
213	CreatedAt time.Time
214	// Version is set by the store on read, like MergeRequest.Version.
215	Version uint64
216}
217
218// IsGeneral reports whether the comment is not attached to a line.
219func (c Comment) IsGeneral() bool { return c.Path == "" }
220
221// HasLine reports whether the diff has a line numbered line on side in the
222// file path: the new path of a file, or the old path of a deleted one. The new
223// side holds the context and addition lines, the old side the context and
224// deletion lines. A file the diff truncated has no lines.
225func (d Diff) HasLine(path string, side DiffSide, line int) bool {
226	if path == "" || line < 1 {
227		return false
228	}
229	for _, f := range d.Files {
230		name := f.NewPath
231		if f.Status == FileDeleted {
232			name = f.OldPath
233		}
234		if name != path {
235			continue
236		}
237		for _, h := range f.Hunks {
238			for _, l := range h.Lines {
239				switch {
240				case side == SideNew && l.Kind != LineDeletion && l.NewNumber == line:
241					return true
242				case side == SideOld && l.Kind != LineAddition && l.OldNumber == line:
243					return true
244				}
245			}
246		}
247	}
248	return false
249}
250
251// Identity is a name and an email address, such as the committer of the
252// commits ermite creates.
253type Identity struct {
254	Name, Email string
255}
256
257// ServerCommit is a commit the server creates: a squash or a merge commit.
258type ServerCommit struct {
259	Tree              ObjectID
260	Parents           []ObjectID
261	Author, Committer Signature
262	Message           string
263}
264
265// MaxSquashCommits bounds the commits a squash lists in its message.
266const MaxSquashCommits = 1000
267
268// NewSquashCommit builds the squash commit of a merge request (ADR 0022).
269// commits are the commits of the branch that the target lacks, newest first,
270// as Git lists them, and not empty. The author is the author of the oldest
271// one, with its date; the other distinct authors become Co-authored-by
272// trailers, in order of appearance. The message is the title, " (!<number>)",
273// a blank line, the summaries of the commits oldest first as a list, and the
274// trailers.
275func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
276	oldest := slices.Clone(commits)
277	slices.Reverse(oldest)
278	var first Signature
279	if len(oldest) > 0 {
280		first = oldest[0].Author
281	}
282
283	var msg strings.Builder
284	fmt.Fprintf(&msg, "%s (!%d)\n", mr.Title.String(), mr.Number)
285	if len(oldest) > 0 {
286		msg.WriteString("\n")
287	}
288	var trailers []string
289	seen := map[string]bool{strings.ToLower(first.Email): true}
290	for _, c := range oldest {
291		msg.WriteString("- " + singleLine(c.Summary) + "\n")
292		key := strings.ToLower(c.Author.Email)
293		if !seen[key] {
294			seen[key] = true
295			trailers = append(trailers, "Co-authored-by: "+identLine(c.Author.Name, c.Author.Email))
296		}
297	}
298	if len(trailers) > 0 {
299		msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
300	}
301	return ServerCommit{
302		Tree: tree, Parents: []ObjectID{parent},
303		Author:    first,
304		Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
305		Message:   msg.String(),
306	}
307}
308
309// NewMergeCommit builds the merge commit of a merge request: the tree of the
310// merge, the target tip and the head as parents, and the server identity as
311// author and committer.
312func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313	sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314	return ServerCommit{
315		Tree: tree, Parents: []ObjectID{target, head},
316		Author: sig, Committer: sig,
317		Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318			singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319	}
320}
321
322// Payload returns the text of the commit object without a signature, which is
323// what the signature covers.
324func (c ServerCommit) Payload() []byte { return c.encode("") }
325
326// Signed returns the text of the commit object with the armored SSH signature
327// in its gpgsig header.
328func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
329
330func (c ServerCommit) encode(armored string) []byte {
331	var b strings.Builder
332	b.WriteString("tree " + c.Tree.String() + "\n")
333	for _, p := range c.Parents {
334		b.WriteString("parent " + p.String() + "\n")
335	}
336	b.WriteString("author " + signatureLine(c.Author) + "\n")
337	b.WriteString("committer " + signatureLine(c.Committer) + "\n")
338	if armored != "" {
339		lines := strings.Split(strings.TrimRight(armored, "\n"), "\n")
340		b.WriteString("gpgsig " + lines[0] + "\n")
341		for _, l := range lines[1:] {
342			b.WriteString(" " + l + "\n")
343		}
344	}
345	b.WriteString("\n")
346	b.WriteString(c.Message)
347	return []byte(b.String())
348}
349
350func signatureLine(s Signature) string {
351	return fmt.Sprintf("%s %d +0000", identLine(s.Name, s.Email), s.Time.Unix())
352}
353
354// identLine formats "Name <email>", dropping the characters Git does not
355// allow in an identity.
356func identLine(name, email string) string {
357	clean := func(s string) string {
358		return strings.TrimSpace(strings.Map(func(r rune) rune {
359			if r == '<' || r == '>' || r < 0x20 || r == 0x7f {
360				return -1
361			}
362			return r
363		}, s))
364	}
365	return clean(name) + " <" + clean(email) + ">"
366}
367
368// singleLine replaces the control characters of a commit summary, so that it
369// stays one line of the message.
370func singleLine(s string) string {
371	return strings.Map(func(r rune) rune {
372		if r < 0x20 || r == 0x7f {
373			return ' '
374		}
375		return r
376	}, s)
377}
378
379// Verdict is an immutable review decision on a root note.
380type Verdict uint8
381
382// Review verdicts.
383const (
384	VerdictNone Verdict = iota
385	VerdictApprove
386	VerdictChangesRequested
387)
388
389// String returns the wire name of a verdict.
390func (v Verdict) String() string {
391	switch v {
392	case VerdictApprove:
393		return "approve"
394	case VerdictChangesRequested:
395		return "changes_requested"
396	}
397	return ""
398}
399
400// ParseVerdict parses a review decision.
401func ParseVerdict(s string) (Verdict, error) {
402	switch s {
403	case "":
404		return VerdictNone, nil
405	case "approve":
406		return VerdictApprove, nil
407	case "changes_requested":
408		return VerdictChangesRequested, nil
409	}
410	return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
411}
412
413// IsReply reports whether this note replies to a root.
414func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
415
416// IsRange reports whether this note anchors a range.
417func (c Comment) IsRange() bool { return c.StartLine != 0 }
418
419// AuthorVerdict is an author's latest review decision.
420type AuthorVerdict struct {
421	Author    string
422	Verdict   Verdict
423	CommentID int64
424	CommitID  ObjectID
425	At        time.Time
426	Stale     bool
427}
428
429// CurrentVerdicts returns the latest verdict per author, including deleted notes,
430// in note ID order. Only approvals become stale when the head changes.
431func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
432	latest := make(map[string]Comment)
433	for _, c := range comments {
434		if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
435			latest[c.Author] = c
436		}
437	}
438	out := make([]AuthorVerdict, 0, len(latest))
439	for _, c := range latest {
440		out = append(out, AuthorVerdict{Author: c.Author, Verdict: c.Verdict, CommentID: c.ID, CommitID: c.CommitID, At: c.CreatedAt, Stale: c.Verdict == VerdictApprove && (head == (ObjectID{}) || c.CommitID != head)})
441	}
442	slices.SortFunc(out, func(a, b AuthorVerdict) int {
443		if a.CommentID < b.CommentID {
444			return -1
445		}
446		if a.CommentID > b.CommentID {
447			return 1
448		}
449		return 0
450	})
451	return out
452}
453
454// Limits of a stored Mergeability.
455const (
456	// MaxBehindCount caps the commits counted as behind.
457	MaxBehindCount = 1000
458	// MaxConflictPaths caps the conflicted paths listed.
459	MaxConflictPaths = 20
460)
461
462// MergeabilityState is the outcome of a mergeability check.
463type MergeabilityState string
464
465// Mergeability states.
466const (
467	// MergeabilityChecking means there is no up-to-date result yet; it is never
468	// stored.
469	MergeabilityChecking MergeabilityState = "checking"
470	// MergeabilityUpToDate means the target is an ancestor of the head.
471	MergeabilityUpToDate MergeabilityState = "up_to_date"
472	// MergeabilityBehind means the head lacks commits of the target but merges
473	// cleanly.
474	MergeabilityBehind MergeabilityState = "behind"
475	// MergeabilityConflict means the head does not merge cleanly.
476	MergeabilityConflict MergeabilityState = "conflict"
477)
478
479// Mergeability is whether an open merge request merges with its target, for
480// given tips.
481type Mergeability struct {
482	Repo   RepoName
483	Number int64
484	State  MergeabilityState
485	// TargetID and HeadID are the tips the result is for; zero for a derived
486	// "checking".
487	TargetID, HeadID ObjectID
488	// Behind counts the commits of the target not in the head (behind only),
489	// capped at MaxBehindCount.
490	Behind       int
491	BehindCapped bool
492	// ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493	ConflictPaths          []string
494	ConflictPathsTruncated bool
495	CheckedAt              time.Time
496	// Version is set by the store on read, like MergeRequest.Version.
497	Version uint64
498}
499
500// Allows reports whether the mode may be used given the state, and if not,
501// why.
502func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503	switch mode {
504	case MergeModeFastForward:
505		if m.State == MergeabilityUpToDate {
506			return true, ""
507		}
508		if m.State == MergeabilityBehind {
509			return false, "behind the target: rebase or use squash/merge"
510		}
511	case MergeModeSquash, MergeModeMerge:
512		if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513			return true, ""
514		}
515	}
516	switch m.State {
517	case MergeabilityConflict:
518		return false, "conflicts with the target"
519	case MergeabilityChecking, "":
520		return false, "mergeability is being checked"
521	}
522	return false, "mode not allowed"
523}
524
525// IsFor reports whether the result is for these tips, that is not stale.
526func (m Mergeability) IsFor(target, head ObjectID) bool {
527	return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528}
529
530// MergeTreeResult is the outcome of merging two commits without a commit.
531type MergeTreeResult struct {
532	// Tree is the merged tree; zero when not clean.
533	Tree          ObjectID
534	Clean         bool
535	ConflictPaths []string
536	Truncated     bool
537}
538
539// Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated
540// set when some were dropped. Adapters return the raw result; the bound is
541// the domain's.
542func (r MergeTreeResult) Bounded() MergeTreeResult {
543	kept := r.ConflictPaths
544	if len(kept) > MaxConflictPaths {
545		kept = kept[:MaxConflictPaths]
546		r.Truncated = true
547	}
548	if kept != nil {
549		// Exact-sized copies: neither the backing array nor the git output the
550		// paths were cut from stays referenced.
551		out := make([]string, len(kept))
552		for i, p := range kept {
553			out[i] = strings.Clone(p)
554		}
555		r.ConflictPaths = out
556	}
557	return r
558}
559
560// BehindLimit is the limit to give a count of commits behind so that
561// BoundBehind can tell whether MaxBehindCount was exceeded.
562const BehindLimit = MaxBehindCount + 1
563
564// BoundBehind caps a raw count of commits behind (counted up to BehindLimit)
565// at MaxBehindCount; capped is true when the count was over it.
566func BoundBehind(n int) (count int, capped bool) {
567	if n > MaxBehindCount {
568		return MaxBehindCount, true
569	}
570	return n, false
571}
572
573// IssuesEnabled reports whether the repository exposes issue streams and pages.
574func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff }
575
576// ShowsIssueSubmissionReceipt reports whether submissions await public moderation.
577func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic }
578
579// AcceptsAnonymousIssues reports whether anyone may submit an issue.
580func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic }

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