detached 0e6522c

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)
22
23// MergeModes returns the two modes in their canonical order.
24func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }
25
26// ParseMergeMode parses "fast-forward" or "squash".
27func ParseMergeMode(s string) (MergeMode, error) {
28	m := MergeMode(s)
29	if slices.Contains(MergeModes(), m) {
30		return m, nil
31	}
32	return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)
33}
34
35// String returns the mode as text.
36func (m MergeMode) String() string { return string(m) }
37
38// RepoSettings are the settings of a repository that govern its merge
39// requests.
40type RepoSettings struct {
41	// AllowedMergeModes is never empty, in canonical order, without
42	// duplicates.
43	AllowedMergeModes []MergeMode
44	// FailingChecksBlock says that a failing check prevents the merge.
45	FailingChecksBlock bool
46}
47
48// DefaultRepoSettings are the settings of a new repository: both modes, and
49// failing checks block the merge.
50func DefaultRepoSettings() RepoSettings {
51	return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}
52}
53
54// IsZero reports whether s holds no mode, which only the zero value does.
55func (s RepoSettings) IsZero() bool { return len(s.AllowedMergeModes) == 0 }
56
57// Allows reports whether the mode may be used.
58func (s RepoSettings) Allows(m MergeMode) bool { return slices.Contains(s.AllowedMergeModes, m) }
59
60// NormalizeMergeModes validates a list of modes for the settings: it must not
61// be empty or hold an unknown mode. The result is in canonical order, without
62// duplicates.
63func NormalizeMergeModes(modes []MergeMode) ([]MergeMode, error) {
64	if len(modes) == 0 {
65		return nil, fmt.Errorf("%w: at least one merge mode is required", ErrInvalid)
66	}
67	for _, m := range modes {
68		if _, err := ParseMergeMode(m.String()); err != nil {
69			return nil, err
70		}
71	}
72	var out []MergeMode
73	for _, m := range MergeModes() {
74		if slices.Contains(modes, m) {
75			out = append(out, m)
76		}
77	}
78	return out, nil
79}
80
81// MRState is where a merge request is in its life.
82type MRState string
83
84// Merge request states.
85const (
86	// MRStateOpen awaits review.
87	MRStateOpen MRState = "open"
88	// MRStateMerged is final: the branch was merged.
89	MRStateMerged MRState = "merged"
90	// MRStateClosed is final: the request was closed without merging.
91	MRStateClosed MRState = "closed"
92)
93
94// DiffSide is the side of a diff a comment is attached to.
95type DiffSide string
96
97// Diff sides. The empty side marks a general comment.
98const (
99	// SideNone is the side of a general comment.
100	SideNone DiffSide = ""
101	// SideOld counts lines of the old file.
102	SideOld DiffSide = "old"
103	// SideNew counts lines of the new file.
104	SideNew DiffSide = "new"
105)
106
107// MergeRequest asks to merge one branch into another of the same repository.
108type MergeRequest struct {
109	Repo   RepoName
110	Number int64
111	Title  MRTitle
112	// Description may be empty.
113	Description PlainText
114	// Source and Target are short branch names.
115	Source, Target string
116	State          MRState
117	// Author is the label of the token that opened it, "root" or "owner".
118	Author    string
119	CreatedAt time.Time
120	UpdatedAt time.Time
121	// HeadID is the head of the source branch: stored when the request is
122	// merged or closed, and filled from Git at every read while it is open
123	// (zero when the branch is gone).
124	HeadID ObjectID
125	// BaseID is the merge base of the target and the head when the request
126	// was merged or closed; zero while it is open.
127	BaseID ObjectID
128	// MergedCommitID is the tip of the target after the merge; zero unless
129	// merged.
130	MergedCommitID ObjectID
131	// MergeMode is the mode used; empty unless merged.
132	MergeMode MergeMode
133	// CommentSeq is the id of the last comment of the request.
134	CommentSeq int64
135	// Version is set by the store on read: Update fails with ErrConflict when
136	// the stored request changed since. Opaque to everyone else.
137	Version uint64
138}
139
140// draftPrefixes mark a draft at the start of a title, compared without regard
141// to case, as Forgejo and Gitea do.
142var draftPrefixes = []string{"wip:", "[wip]"}
143
144// IsDraft reports whether the title marks the request as a draft: it starts
145// with "WIP:" or "[WIP]". A draft cannot be merged.
146func (mr MergeRequest) IsDraft() bool {
147	_, ok := draftRest(mr.Title.String())
148	return ok
149}
150
151// ReadyTitle is the title without its draft prefixes (all of them, so that
152// "WIP: [WIP] x" is no longer a draft), and false when the title has none or
153// nothing would be left of it.
154func ReadyTitle(t MRTitle) (MRTitle, bool) {
155	rest, ok := draftRest(t.String())
156	if !ok {
157		return MRTitle{}, false
158	}
159	for {
160		next, more := draftRest(rest)
161		if !more {
162			break
163		}
164		rest = next
165	}
166	ready, err := ParseMRTitle(rest)
167	if err != nil {
168		return MRTitle{}, false
169	}
170	return ready, true
171}
172
173// draftRest is the title after its draft prefix and the spaces that follow.
174func draftRest(title string) (string, bool) {
175	t := strings.TrimLeft(title, " ")
176	for _, p := range draftPrefixes {
177		if len(t) >= len(p) && strings.EqualFold(t[:len(p)], p) {
178			return strings.TrimLeft(t[len(p):], " "), true
179		}
180	}
181	return "", false
182}
183
184// IsOpen reports whether the request awaits review.
185func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
186
187// Comment is a note on a merge request, general or on a line of its diff.
188type Comment struct {
189	ReplyTo   int64
190	StartLine int
191	Verdict   Verdict
192	EditedAt  time.Time
193	Deleted   bool
194	// ID is unique within the merge request, in order of creation.
195	ID     int64
196	Author string
197	// Path, Line and Side are empty for a general comment.
198	Path string
199	Line int
200	Side DiffSide
201	// CommitID is the head of the request when the comment was written; zero
202	// when the source branch was gone.
203	CommitID  ObjectID
204	Text      PlainText
205	Resolved  bool
206	CreatedAt time.Time
207	// Version is set by the store on read, like MergeRequest.Version.
208	Version uint64
209}
210
211// IsGeneral reports whether the comment is not attached to a line.
212func (c Comment) IsGeneral() bool { return c.Path == "" }
213
214// HasLine reports whether the diff has a line numbered line on side in the
215// file path: the new path of a file, or the old path of a deleted one. The new
216// side holds the context and addition lines, the old side the context and
217// deletion lines. A file the diff truncated has no lines.
218func (d Diff) HasLine(path string, side DiffSide, line int) bool {
219	if path == "" || line < 1 {
220		return false
221	}
222	for _, f := range d.Files {
223		name := f.NewPath
224		if f.Status == FileDeleted {
225			name = f.OldPath
226		}
227		if name != path {
228			continue
229		}
230		for _, h := range f.Hunks {
231			for _, l := range h.Lines {
232				switch {
233				case side == SideNew && l.Kind != LineDeletion && l.NewNumber == line:
234					return true
235				case side == SideOld && l.Kind != LineAddition && l.OldNumber == line:
236					return true
237				}
238			}
239		}
240	}
241	return false
242}
243
244// Identity is a name and an email address, such as the committer of the
245// commits ermite creates.
246type Identity struct {
247	Name, Email string
248}
249
250// SquashCommit is the commit a squash merge creates: the tree of the head on
251// top of the target.
252type SquashCommit struct {
253	Tree, Parent      ObjectID
254	Author, Committer Signature
255	Message           string
256}
257
258// MaxSquashCommits bounds the commits a squash lists in its message.
259const MaxSquashCommits = 1000
260
261// NewSquashCommit builds the squash commit of a merge request (ADR 0022).
262// commits are the commits of the branch that the target lacks, newest first,
263// as Git lists them, and not empty. The author is the author of the oldest
264// one, with its date; the other distinct authors become Co-authored-by
265// trailers, in order of appearance. The message is the title, " (!<number>)",
266// a blank line, the summaries of the commits oldest first as a list, and the
267// trailers.
268func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
269	oldest := slices.Clone(commits)
270	slices.Reverse(oldest)
271	var first Signature
272	if len(oldest) > 0 {
273		first = oldest[0].Author
274	}
275
276	var msg strings.Builder
277	fmt.Fprintf(&msg, "%s (!%d)\n", mr.Title.String(), mr.Number)
278	if len(oldest) > 0 {
279		msg.WriteString("\n")
280	}
281	var trailers []string
282	seen := map[string]bool{strings.ToLower(first.Email): true}
283	for _, c := range oldest {
284		msg.WriteString("- " + singleLine(c.Summary) + "\n")
285		key := strings.ToLower(c.Author.Email)
286		if !seen[key] {
287			seen[key] = true
288			trailers = append(trailers, "Co-authored-by: "+identLine(c.Author.Name, c.Author.Email))
289		}
290	}
291	if len(trailers) > 0 {
292		msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
293	}
294	return SquashCommit{
295		Tree: tree, Parent: parent,
296		Author:    first,
297		Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
298		Message:   msg.String(),
299	}
300}
301
302// Payload returns the text of the commit object without a signature, which is
303// what the signature covers.
304func (c SquashCommit) Payload() []byte { return c.encode("") }
305
306// Signed returns the text of the commit object with the armored SSH signature
307// in its gpgsig header.
308func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
309
310func (c SquashCommit) encode(armored string) []byte {
311	var b strings.Builder
312	b.WriteString("tree " + c.Tree.String() + "\n")
313	b.WriteString("parent " + c.Parent.String() + "\n")
314	b.WriteString("author " + signatureLine(c.Author) + "\n")
315	b.WriteString("committer " + signatureLine(c.Committer) + "\n")
316	if armored != "" {
317		lines := strings.Split(strings.TrimRight(armored, "\n"), "\n")
318		b.WriteString("gpgsig " + lines[0] + "\n")
319		for _, l := range lines[1:] {
320			b.WriteString(" " + l + "\n")
321		}
322	}
323	b.WriteString("\n")
324	b.WriteString(c.Message)
325	return []byte(b.String())
326}
327
328func signatureLine(s Signature) string {
329	return fmt.Sprintf("%s %d +0000", identLine(s.Name, s.Email), s.Time.Unix())
330}
331
332// identLine formats "Name <email>", dropping the characters Git does not
333// allow in an identity.
334func identLine(name, email string) string {
335	clean := func(s string) string {
336		return strings.TrimSpace(strings.Map(func(r rune) rune {
337			if r == '<' || r == '>' || r < 0x20 || r == 0x7f {
338				return -1
339			}
340			return r
341		}, s))
342	}
343	return clean(name) + " <" + clean(email) + ">"
344}
345
346// singleLine replaces the control characters of a commit summary, so that it
347// stays one line of the message.
348func singleLine(s string) string {
349	return strings.Map(func(r rune) rune {
350		if r < 0x20 || r == 0x7f {
351			return ' '
352		}
353		return r
354	}, s)
355}
356
357// Verdict is an immutable review decision on a root note.
358type Verdict uint8
359
360// Review verdicts.
361const (
362	VerdictNone Verdict = iota
363	VerdictApprove
364	VerdictChangesRequested
365)
366
367// String returns the wire name of a verdict.
368func (v Verdict) String() string {
369	switch v {
370	case VerdictApprove:
371		return "approve"
372	case VerdictChangesRequested:
373		return "changes_requested"
374	}
375	return ""
376}
377
378// ParseVerdict parses a review decision.
379func ParseVerdict(s string) (Verdict, error) {
380	switch s {
381	case "":
382		return VerdictNone, nil
383	case "approve":
384		return VerdictApprove, nil
385	case "changes_requested":
386		return VerdictChangesRequested, nil
387	}
388	return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
389}
390
391// IsReply reports whether this note replies to a root.
392func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
393
394// IsRange reports whether this note anchors a range.
395func (c Comment) IsRange() bool { return c.StartLine != 0 }
396
397// AuthorVerdict is an author's latest review decision.
398type AuthorVerdict struct {
399	Author    string
400	Verdict   Verdict
401	CommentID int64
402	CommitID  ObjectID
403	At        time.Time
404	Stale     bool
405}
406
407// CurrentVerdicts returns the latest verdict per author, including deleted notes,
408// in note ID order. Only approvals become stale when the head changes.
409func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
410	latest := make(map[string]Comment)
411	for _, c := range comments {
412		if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
413			latest[c.Author] = c
414		}
415	}
416	out := make([]AuthorVerdict, 0, len(latest))
417	for _, c := range latest {
418		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)})
419	}
420	slices.SortFunc(out, func(a, b AuthorVerdict) int {
421		if a.CommentID < b.CommentID {
422			return -1
423		}
424		if a.CommentID > b.CommentID {
425			return 1
426		}
427		return 0
428	})
429	return out
430}

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