1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "fmt"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"
14 "bnema.dev/ermite/internal/ports/mocks"
15 "bnema.dev/ermite/internal/usecase"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"
19)
20
21func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
22 t.Helper()
23 r := newMRRig(t)
24 r.seed()
25 _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
26 require.NoError(t, err)
27 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
28 r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links})
29 issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
30 return r, links, issues
31}
32
33func TestIssueLinksTextsPushAndReferences(t *testing.T) {
34 r, links, issues := linkedRig(t)
35 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
36 require.NoError(t, err)
37 r.feature("feat")
38 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
39 require.NoError(t, err)
40 got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
41 require.NoError(t, err)
42 require.Len(t, got.References, 1)
43 assert.Equal(t, mr.Title, got.References[0].Title)
44 other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
45 require.NoError(t, err)
46 note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)})
47 require.NoError(t, err)
48 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
49 require.NoError(t, err)
50 require.Len(t, got.References, 1)
51 assert.False(t, got.References[0].Closes)
52 indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
53 require.NoError(t, err)
54 require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
55 _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
56 require.NoError(t, err)
57 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
58 require.NoError(t, err)
59 assert.Empty(t, got.References)
60 head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
61 r.push("feat")
62 links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
63 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
64 require.NoError(t, err)
65 require.Len(t, got.References, 1)
66 assert.True(t, got.References[0].Closes)
67 stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
68 require.NoError(t, err)
69 require.Len(t, stored, 2)
70 _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
71 require.NoError(t, err)
72 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
73 require.NoError(t, err)
74 assert.True(t, got.Issue.IsOpen())
75 assert.Equal(t, domain.MRStateClosed, got.References[0].State)
76}
77
78func TestIssueLinksMergeClosing(t *testing.T) {
79 for _, mode := range []string{"open", "off", "closed"} {
80 t.Run(mode, func(t *testing.T) {
81 r, _, issues := linkedRig(t)
82 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
83 require.NoError(t, err)
84 if mode == "closed" {
85 issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
86 require.NoError(t, err)
87 }
88 r.feature("feat")
89 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
90 r.push("feat")
91 mr := r.open("feat")
92 if mode == "off" {
93 _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
94 require.NoError(t, err)
95 }
96 issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
97 require.NoError(t, err)
98 r.drain()
99 _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
100 require.NoError(t, err)
101 events := r.drain()
102 got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
103 require.NoError(t, err)
104 refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
105 require.NoError(t, err)
106 require.Len(t, refs, 1)
107 assert.True(t, refs[0].Closes)
108 closedEvents, referenced := 0, 0
109 for _, ev := range events {
110 switch ev.(type) {
111 case domain.IssueClosed:
112 closedEvents++
113 case domain.IssueReferenced:
114 referenced++
115 }
116 }
117 assert.Positive(t, referenced)
118 if mode == "open" {
119 assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
120 assert.False(t, got.IsOpen())
121 assert.Equal(t, 1, closedEvents)
122 reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
123 require.NoError(t, err)
124 assert.Zero(t, reopened.ClosedByMergeRequest)
125 } else {
126 assert.Zero(t, got.ClosedByMergeRequest)
127 assert.Equal(t, 0, closedEvents)
128 if mode == "closed" {
129 assert.Equal(t, issue, got)
130 } else {
131 assert.True(t, got.IsOpen())
132 }
133 }
134 })
135 }
136}
137
138type gatedBranchGit struct {
139 ports.MergeGit
140 entered atomic.Int32
141 started chan struct{}
142 gate chan struct{}
143}
144
145func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
146 if g.entered.Add(1) == 1 {
147 close(g.started)
148 <-g.gate
149 }
150 return g.MergeGit.BranchHead(ctx, name, branch)
151}
152
153func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
154 r, _, issues := linkedRig(t)
155 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
156 require.NoError(t, err)
157 r.feature("feat")
158 mr := r.open("feat")
159 g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
160 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}})
161 repo, err := r.store.Get(r.ctx, r.repo)
162 require.NoError(t, err)
163 done := make(chan error, 2)
164 go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
165 <-g.started
166 // The first reindex read the old texts; a note is added afterwards.
167 _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
168 require.NoError(t, err)
169 go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
170 time.Sleep(100 * time.Millisecond)
171 assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
172 close(g.gate)
173 require.NoError(t, <-done)
174 require.NoError(t, <-done)
175 got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
176 require.NoError(t, err)
177 require.Len(t, got, 1, "the final index reflects the latest content")
178}
179
180func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
181 r, links, issues := linkedRig(t)
182 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
183 require.NoError(t, err)
184 r.feature("feat")
185 mr := r.open("feat")
186 referenced := func() (n int) {
187 for _, ev := range r.drain() {
188 if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
189 n++
190 }
191 }
192 return n
193 }
194 note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
195 require.NoError(t, err)
196 assert.Equal(t, 1, referenced())
197 _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
198 require.NoError(t, err)
199 assert.Equal(t, 1, referenced(), "removing the last mention notifies")
200
201 // closes true -> false emits one event: a commit message fixes the issue,
202 // then is rewritten to a plain mention.
203 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
204 r.push("feat")
205 links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
206 referenced()
207 r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
208 r.push("feat")
209 links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
210 assert.Equal(t, 1, referenced(), "a closing flip notifies once")
211}
212
213func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
214 r, _, issues := linkedRig(t)
215 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
216 require.NoError(t, err)
217 r.sh("checkout", "-q", "-B", "x", "main")
218 r.commit(author, "x.txt", "x", "work on x")
219 r.push("x")
220 r.sh("checkout", "-q", "-B", "feat", "x")
221 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
222 r.push("feat")
223 b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
224 require.NoError(t, err)
225 a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
226 require.NoError(t, err)
227 before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
228 require.NoError(t, err)
229 require.Empty(t, before)
230 _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
231 require.NoError(t, err)
232 r.mrs.Wait(r.ctx)
233 after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
234 require.NoError(t, err)
235 require.Len(t, after, 1)
236 assert.Equal(t, issue.Number, after[0].Issue)
237}
238
239func TestAutoMergeClosesLinkedIssue(t *testing.T) {
240 r, _, issues := linkedRig(t)
241 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
242 require.NoError(t, err)
243 r.feature("feat")
244 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
245 require.NoError(t, err)
246 _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
247 require.NoError(t, err)
248 checks := mocks.NewCIChecksReader(t)
249 r.mrs.SetChecks(checks)
250 enableAuto(t, r, mr)
251 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
252 a := autoEvaluator(r, checks, nil, nil)
253 defer a.Close(r.ctx)
254 require.True(t, a.Evaluate(r.repo))
255 r.mrs.Wait(r.ctx)
256 merged := storedAuto(t, r, mr)
257 require.Equal(t, domain.MRStateMerged, merged.State)
258 require.True(t, merged.AutoMerged)
259 got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
260 require.NoError(t, err)
261 assert.False(t, got.Issue.IsOpen())
262}