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