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 // ID is unique within the merge request, in order of creation.
190 ID int64
191 Author string
192 // Path, Line and Side are empty for a general comment.
193 Path string
194 Line int
195 Side DiffSide
196 // CommitID is the head of the request when the comment was written; zero
197 // when the source branch was gone.
198 CommitID ObjectID
199 Text PlainText
200 Resolved bool
201 CreatedAt time.Time
202 // Version is set by the store on read, like MergeRequest.Version.
203 Version uint64
204}
205
206// IsGeneral reports whether the comment is not attached to a line.
207func (c Comment) IsGeneral() bool { return c.Path == "" }
208
209// HasLine reports whether the diff has a line numbered line on side in the
210// file path: the new path of a file, or the old path of a deleted one. The new
211// side holds the context and addition lines, the old side the context and
212// deletion lines. A file the diff truncated has no lines.
213func (d Diff) HasLine(path string, side DiffSide, line int) bool {
214 if path == "" || line < 1 {
215 return false
216 }
217 for _, f := range d.Files {
218 name := f.NewPath
219 if f.Status == FileDeleted {
220 name = f.OldPath
221 }
222 if name != path {
223 continue
224 }
225 for _, h := range f.Hunks {
226 for _, l := range h.Lines {
227 switch {
228 case side == SideNew && l.Kind != LineDeletion && l.NewNumber == line:
229 return true
230 case side == SideOld && l.Kind != LineAddition && l.OldNumber == line:
231 return true
232 }
233 }
234 }
235 }
236 return false
237}
238
239// Identity is a name and an email address, such as the committer of the
240// commits ermite creates.
241type Identity struct {
242 Name, Email string
243}
244
245// SquashCommit is the commit a squash merge creates: the tree of the head on
246// top of the target.
247type SquashCommit struct {
248 Tree, Parent ObjectID
249 Author, Committer Signature
250 Message string
251}
252
253// MaxSquashCommits bounds the commits a squash lists in its message.
254const MaxSquashCommits = 1000
255
256// NewSquashCommit builds the squash commit of a merge request (ADR 0022).
257// commits are the commits of the branch that the target lacks, newest first,
258// as Git lists them, and not empty. The author is the author of the oldest
259// one, with its date; the other distinct authors become Co-authored-by
260// trailers, in order of appearance. The message is the title, " (!<number>)",
261// a blank line, the summaries of the commits oldest first as a list, and the
262// trailers.
263func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
264 oldest := slices.Clone(commits)
265 slices.Reverse(oldest)
266 var first Signature
267 if len(oldest) > 0 {
268 first = oldest[0].Author
269 }
270
271 var msg strings.Builder
272 fmt.Fprintf(&msg, "%s (!%d)\n", mr.Title.String(), mr.Number)
273 if len(oldest) > 0 {
274 msg.WriteString("\n")
275 }
276 var trailers []string
277 seen := map[string]bool{strings.ToLower(first.Email): true}
278 for _, c := range oldest {
279 msg.WriteString("- " + singleLine(c.Summary) + "\n")
280 key := strings.ToLower(c.Author.Email)
281 if !seen[key] {
282 seen[key] = true
283 trailers = append(trailers, "Co-authored-by: "+identLine(c.Author.Name, c.Author.Email))
284 }
285 }
286 if len(trailers) > 0 {
287 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
288 }
289 return SquashCommit{
290 Tree: tree, Parent: parent,
291 Author: first,
292 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
293 Message: msg.String(),
294 }
295}
296
297// Payload returns the text of the commit object without a signature, which is
298// what the signature covers.
299func (c SquashCommit) Payload() []byte { return c.encode("") }
300
301// Signed returns the text of the commit object with the armored SSH signature
302// in its gpgsig header.
303func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
304
305func (c SquashCommit) encode(armored string) []byte {
306 var b strings.Builder
307 b.WriteString("tree " + c.Tree.String() + "\n")
308 b.WriteString("parent " + c.Parent.String() + "\n")
309 b.WriteString("author " + signatureLine(c.Author) + "\n")
310 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
311 if armored != "" {
312 lines := strings.Split(strings.TrimRight(armored, "\n"), "\n")
313 b.WriteString("gpgsig " + lines[0] + "\n")
314 for _, l := range lines[1:] {
315 b.WriteString(" " + l + "\n")
316 }
317 }
318 b.WriteString("\n")
319 b.WriteString(c.Message)
320 return []byte(b.String())
321}
322
323func signatureLine(s Signature) string {
324 return fmt.Sprintf("%s %d +0000", identLine(s.Name, s.Email), s.Time.Unix())
325}
326
327// identLine formats "Name <email>", dropping the characters Git does not
328// allow in an identity.
329func identLine(name, email string) string {
330 clean := func(s string) string {
331 return strings.TrimSpace(strings.Map(func(r rune) rune {
332 if r == '<' || r == '>' || r < 0x20 || r == 0x7f {
333 return -1
334 }
335 return r
336 }, s))
337 }
338 return clean(name) + " <" + clean(email) + ">"
339}
340
341// singleLine replaces the control characters of a commit summary, so that it
342// stays one line of the message.
343func singleLine(s string) string {
344 return strings.Map(func(r rune) rune {
345 if r < 0x20 || r == 0x7f {
346 return ' '
347 }
348 return r
349 }, s)
350}