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 issueStore := r.store.Issues()
28 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: issueStore, MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
29 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})
30 issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: issueStore, MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
31 return r, links, issues
32}
33
34func TestIssueLinksTextsPushAndReferences(t *testing.T) {
35 r, links, issues := linkedRig(t)
36 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
37 require.NoError(t, err)
38 r.feature("feat")
39 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
40 require.NoError(t, err)
41 got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
42 require.NoError(t, err)
43 require.Len(t, got.References, 1)
44 assert.Equal(t, mr.Title, got.References[0].Title)
45 other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
46 require.NoError(t, err)
47 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)})
48 require.NoError(t, err)
49 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
50 require.NoError(t, err)
51 require.Len(t, got.References, 1)
52 assert.False(t, got.References[0].Closes)
53 indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
54 require.NoError(t, err)
55 require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
56 _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
57 require.NoError(t, err)
58 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
59 require.NoError(t, err)
60 assert.Empty(t, got.References)
61 head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
62 r.push("feat")
63 links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
64 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
65 require.NoError(t, err)
66 require.Len(t, got.References, 1)
67 assert.True(t, got.References[0].Closes)
68 stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
69 require.NoError(t, err)
70 require.Len(t, stored, 2)
71 _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
72 require.NoError(t, err)
73 got, err = issues.Get(r.ctx, root, r.repo, other.Number)
74 require.NoError(t, err)
75 assert.True(t, got.Issue.IsOpen())
76 assert.Equal(t, domain.MRStateClosed, got.References[0].State)
77}
78
79func TestIssueLinksMergeClosing(t *testing.T) {
80 for _, mode := range []string{"open", "off", "closed"} {
81 t.Run(mode, func(t *testing.T) {
82 r, _, issues := linkedRig(t)
83 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
84 require.NoError(t, err)
85 if mode == "closed" {
86 issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
87 require.NoError(t, err)
88 }
89 r.feature("feat")
90 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
91 r.push("feat")
92 mr := r.open("feat")
93 if mode == "off" {
94 _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
95 require.NoError(t, err)
96 }
97 issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
98 require.NoError(t, err)
99 r.drain()
100 _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
101 require.NoError(t, err)
102 events := r.drain()
103 detail, err := issues.Get(r.ctx, root, r.repo, issue.Number)
104 if mode == "off" {
105 stored, readErr := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
106 require.NoError(t, readErr)
107 detail.Issue = stored
108 } else {
109 require.NoError(t, err)
110 }
111 got := detail.Issue
112 refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
113 require.NoError(t, err)
114 require.Len(t, refs, 1)
115 assert.True(t, refs[0].Closes)
116 closedEvents, referenced := 0, 0
117 for _, ev := range events {
118 switch ev.(type) {
119 case domain.IssueResolved:
120 closedEvents++
121 case domain.IssueReferenced:
122 referenced++
123 }
124 }
125 assert.Positive(t, referenced)
126 if mode == "open" {
127 assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
128 assert.Equal(t, domain.IssueStateResolved, got.State)
129 assert.Equal(t, 1, closedEvents)
130 persisted, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
131 require.NoError(t, err)
132 assert.Equal(t, got, persisted)
133 history, err := r.store.Issues().ListEvents(r.ctx, r.repo, issue.Number)
134 require.NoError(t, err)
135 require.NotEmpty(t, history)
136 last := history[len(history)-1]
137 assert.Equal(t, domain.IssueEventResolved, last.Kind)
138 assert.Equal(t, domain.AuthorName(root), last.Actor)
139 assert.Equal(t, mr.Number, last.MergeRequest)
140 page, err := r.store.Issues().List(r.ctx, r.repo, ports.ListIssuesParams{State: domain.IssueStateOpen})
141 require.NoError(t, err)
142 assert.Empty(t, page.Issues)
143
144 reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
145 require.NoError(t, err)
146 assert.Zero(t, reopened.ClosedByMergeRequest)
147 persisted, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
148 require.NoError(t, err)
149 assert.Equal(t, domain.IssueStateOpen, persisted.State)
150 assert.Zero(t, persisted.ClosedByMergeRequest)
151 history, err = r.store.Issues().ListEvents(r.ctx, r.repo, issue.Number)
152 require.NoError(t, err)
153 assert.Equal(t, domain.IssueEventReopened, history[len(history)-1].Kind)
154 page, err = r.store.Issues().List(r.ctx, r.repo, ports.ListIssuesParams{State: domain.IssueStateOpen})
155 require.NoError(t, err)
156 require.Len(t, page.Issues, 1)
157 assert.Equal(t, persisted, page.Issues[0])
158
159 } else {
160 assert.Zero(t, got.ClosedByMergeRequest)
161 assert.Equal(t, 0, closedEvents)
162 if mode == "closed" {
163 assert.Equal(t, issue, got)
164 } else {
165 assert.True(t, got.IsOpen())
166 }
167 }
168 })
169 }
170}
171
172type gatedBranchGit struct {
173 ports.MergeGit
174 entered atomic.Int32
175 started chan struct{}
176 gate chan struct{}
177}
178
179func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
180 if g.entered.Add(1) == 1 {
181 close(g.started)
182 <-g.gate
183 }
184 return g.MergeGit.BranchHead(ctx, name, branch)
185}
186
187func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
188 r, _, issues := linkedRig(t)
189 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
190 require.NoError(t, err)
191 r.feature("feat")
192 mr := r.open("feat")
193 g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
194 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{}})
195 repo, err := r.store.Get(r.ctx, r.repo)
196 require.NoError(t, err)
197 done := make(chan error, 2)
198 go func() { done <- links.Reindex(r.ctx, domain.Principal{}, repo, mr.Number) }()
199 <-g.started
200 // The first reindex read the old texts; a note is added afterwards.
201 _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
202 require.NoError(t, err)
203 go func() { done <- links.Reindex(r.ctx, domain.Principal{}, repo, mr.Number) }()
204 time.Sleep(100 * time.Millisecond)
205 assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
206 close(g.gate)
207 require.NoError(t, <-done)
208 require.NoError(t, <-done)
209 got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
210 require.NoError(t, err)
211 require.Len(t, got, 1, "the final index reflects the latest content")
212}
213
214func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
215 r, links, issues := linkedRig(t)
216 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
217 require.NoError(t, err)
218 r.feature("feat")
219 mr := r.open("feat")
220 referenced := func() (n int) {
221 for _, ev := range r.drain() {
222 if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
223 n++
224 }
225 }
226 return n
227 }
228 note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
229 require.NoError(t, err)
230 assert.Equal(t, 1, referenced())
231 _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
232 require.NoError(t, err)
233 assert.Equal(t, 1, referenced(), "removing the last mention notifies")
234
235 // closes true -> false emits one event: a commit message fixes the issue,
236 // then is rewritten to a plain mention.
237 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
238 r.push("feat")
239 links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
240 referenced()
241 r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
242 r.push("feat")
243 links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
244 assert.Equal(t, 1, referenced(), "a closing flip notifies once")
245}
246
247func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
248 r, _, issues := linkedRig(t)
249 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
250 require.NoError(t, err)
251 r.sh("checkout", "-q", "-B", "x", "main")
252 r.commit(author, "x.txt", "x", "work on x")
253 r.push("x")
254 r.sh("checkout", "-q", "-B", "feat", "x")
255 head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
256 r.push("feat")
257 b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
258 require.NoError(t, err)
259 a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
260 require.NoError(t, err)
261 before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
262 require.NoError(t, err)
263 require.Empty(t, before)
264 _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
265 require.NoError(t, err)
266 r.mrs.Wait(r.ctx)
267 after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
268 require.NoError(t, err)
269 require.Len(t, after, 1)
270 assert.Equal(t, issue.Number, after[0].Issue)
271}
272
273func TestAutoMergeClosesLinkedIssue(t *testing.T) {
274 r, _, issues := linkedRig(t)
275 issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
276 require.NoError(t, err)
277 r.feature("feat")
278 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
279 require.NoError(t, err)
280 _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
281 require.NoError(t, err)
282 checks := mocks.NewCIChecksReader(t)
283 r.mrs.SetChecks(checks)
284 enableAuto(t, r, mr)
285 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
286 a := autoEvaluator(r, checks, nil, nil)
287 defer a.Close(r.ctx)
288 require.True(t, a.Evaluate(r.repo))
289 r.mrs.Wait(r.ctx)
290 merged := storedAuto(t, r, mr)
291 require.Equal(t, domain.MRStateMerged, merged.State)
292 require.True(t, merged.AutoMerged)
293 got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
294 require.NoError(t, err)
295 assert.False(t, got.Issue.IsOpen())
296}
297
298func TestMergeResolutionActorAndReferenceEvent(t *testing.T) {
299 for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
300 t.Run(string(state), func(t *testing.T) {
301 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
302 links := mocks.NewIssueLinkStore(t)
303 clock := mocks.NewClock(t)
304 clock.EXPECT().Now().Return(now).Maybe()
305 p := issuePrincipal(domain.ScopeIssueWrite)
306 title, err := domain.ParseMRTitle("Fixes #3")
307 require.NoError(t, err)
308 mr := domain.MergeRequest{Repo: u.repo.Name, Number: 4, Title: title, State: domain.MRStateMerged}
309 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(4)).Return(mr, nil)
310 u.mrs.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(4)).Return(nil, nil)
311 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(domain.Issue{Repo: u.repo.Name, Number: 3, State: state}, nil).Twice()
312 links.EXPECT().ReplaceLinks(mock.Anything, u.repo.Name, int64(4), []int64{3}, []int64{3}, ports.ReferenceEvent{Actor: "agent-1", At: now}).Return(nil, nil, nil)
313 links.EXPECT().LinksOfMergeRequest(mock.Anything, u.repo.Name, int64(4)).Return(nil, nil)
314 if state == domain.IssueStateOpen {
315 u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateResolved && i.ClosedByMergeRequest == 4 }), mock.MatchedBy(func(events []domain.IssueEvent) bool {
316 return len(events) == 1 && events[0].Kind == domain.IssueEventResolved && events[0].MergeRequest == 4 && events[0].Actor == "agent-1" && events[0].CreatedAt.Equal(now)
317 })).Return(nil).Once()
318 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool { return e.EventKind() == "issue.resolved" })).Return().Once()
319 }
320 svc := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: u.meta, Issues: u.store, MRs: u.mrs, Links: links, Bus: u.bus, Clock: clock})
321 svc.OnMerged(context.Background(), p, u.repo, mr)
322 })
323 }
324}