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