detached 1386983

feat(issues): index merge request mentions of issues and close linked issues on merge

commit
1386983084dffedfbd09ffc15d0fc5a3a880c317
author
brice · · 2026-10-20 15:10 UTC
parent
8a27537
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +705 -9
M internal/app/forge.go +4 -2
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364364 })
365365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370372 })
371373 f.Mergeability.Start()
372374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375377 })
376378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364 })364 })
365 f.Pushes.WithMergeability(f.Mergeability)365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370 })372 })
371 f.Mergeability.Start()373 f.Mergeability.Start()
372 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375 })377 })
376 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
M internal/ports/dto.go +12 -2
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242242 Body string
243243 }
244244
245-// IssueDetail is an issue with its comments.
245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246254 type IssueDetail struct {
247- Issue domain.Issue
255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248258 // Comments are oldest first.
249259 Comments []domain.IssueComment
250260 }
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242 Body string242 Body string
243 }243 }
244 244
245-// IssueDetail is an issue with its comments.245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246 type IssueDetail struct {254 type IssueDetail struct {
247- Issue domain.Issue255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248 // Comments are oldest first.258 // Comments are oldest first.
249 Comments []domain.IssueComment259 Comments []domain.IssueComment
250 }260 }
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
@@ -0,0 +1,262 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
@@ -0,0 +1,262 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
M internal/ports/ports.go +8 -0
@@ -552,3 +552,11 @@ type IssueLinkStore interface {
552552 type Clock interface {
553553 Now() time.Time
554554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
@@ -552,3 +552,11 @@ type IssueLinkStore interface {
552 type Clock interface {552 type Clock interface {
553 Now() time.Time553 Now() time.Time
554 }554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
A internal/usecase/issue_links.go +229 -0
@@ -0,0 +1,229 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/bnema/zerowrap"
15+)
16+
17+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
18+const MaxIssueLinksPerIssue = 100
19+const maxLinkedMRsPerPush = 50
20+
21+type IssueLinksConfig struct {
22+ Meta ports.RepoMetaStore
23+ Issues ports.IssueStore
24+ MRs ports.MergeRequestStore
25+ Links ports.IssueLinkStore
26+ Git ports.MergeGit
27+ Bus ports.EventBus
28+ Clock ports.Clock
29+}
30+
31+// IssueLinks maintains the derived index and applies closing references on merge.
32+type IssueLinks struct{ cfg IssueLinksConfig }
33+
34+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
35+
36+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks { return &IssueLinks{cfg: cfg} }
37+
38+// Reindex replaces links from the current texts and bounded commit range.
39+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
40+ _, err := l.reindex(ctx, repo, number)
41+ return err
42+}
43+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
44+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
45+ if err != nil {
46+ return nil, err
47+ }
48+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
49+ if err != nil {
50+ return nil, err
51+ }
52+ var texts, messages []string
53+ for _, note := range notes {
54+ if !note.Deleted {
55+ texts = append(texts, note.Text.String())
56+ }
57+ }
58+ if mr.IsOpen() {
59+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
60+ if errors.Is(err, domain.ErrNotFound) {
61+ mr.HeadID = domain.ObjectID{}
62+ } else if err != nil {
63+ return nil, err
64+ }
65+ }
66+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
67+ if err != nil {
68+ return nil, err
69+ }
70+ has := !mr.HeadID.IsZero()
71+ if has && !mr.IsOpen() {
72+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
73+ if err != nil {
74+ return nil, err
75+ }
76+ }
77+ if has {
78+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
79+ if err != nil {
80+ return nil, err
81+ }
82+ if more {
83+ log := zerowrap.FromCtx(ctx)
84+ log.Debug().Msg("issue link commit range truncated")
85+ }
86+ for _, commit := range commits {
87+ messages = append(messages, commit.Message)
88+ }
89+ }
90+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
91+ var mentions, closes []int64
92+ for _, n := range refs.Mentions {
93+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
94+ if errors.Is(err, domain.ErrNotFound) {
95+ continue
96+ }
97+ if err != nil {
98+ return nil, err
99+ }
100+ mentions = append(mentions, n)
101+ if slices.Contains(refs.Closes, n) {
102+ closes = append(closes, n)
103+ }
104+ }
105+ for range maxWriteRetries {
106+ added, _, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
107+ if errors.Is(err, domain.ErrConflict) {
108+ continue
109+ }
110+ if err != nil {
111+ return nil, err
112+ }
113+ for _, link := range added {
114+ l.notify(ctx, repo, link)
115+ }
116+ return closes, nil
117+ }
118+ return nil, domain.ErrConflict
119+}
120+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
121+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
122+}
123+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
124+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
125+}
126+func linkWarning(ctx context.Context, err error) {
127+ if err != nil {
128+ log := zerowrap.FromCtx(ctx)
129+ log.Warn().Err(err).Msg("issue links not updated")
130+ }
131+}
132+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
133+ ctx, cancel := linkContext(ctx)
134+ defer cancel()
135+ linkWarning(ctx, l.Reindex(ctx, repo, number))
136+}
137+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
138+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
139+ linkWarning(ctx, err)
140+ if err == nil {
141+ for _, link := range links {
142+ l.notify(ctx, repo, link)
143+ }
144+ }
145+}
146+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
147+ ctx, cancel := linkContext(ctx)
148+ defer cancel()
149+ l.settled(ctx, repo, mr)
150+}
151+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
152+ ctx, cancel := linkContext(ctx)
153+ defer cancel()
154+ var branches []string
155+ for _, up := range updates {
156+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
157+ branches = append(branches, b)
158+ }
159+ }
160+ if len(branches) == 0 {
161+ return
162+ }
163+ repo, err := l.cfg.Meta.Get(ctx, name)
164+ if err != nil {
165+ linkWarning(ctx, err)
166+ return
167+ }
168+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
169+ if err != nil {
170+ linkWarning(ctx, err)
171+ return
172+ }
173+ if truncated {
174+ log := zerowrap.FromCtx(ctx)
175+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
176+ }
177+ for _, n := range numbers {
178+ linkWarning(ctx, l.Reindex(ctx, repo, n))
179+ }
180+}
181+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
182+ ctx, cancel := linkContext(ctx)
183+ defer cancel()
184+ defer l.settled(ctx, repo, mr)
185+ closes, err := l.reindex(ctx, repo, mr.Number)
186+ if err != nil {
187+ linkWarning(ctx, err)
188+ return
189+ }
190+ if !repo.Settings.IssuesEnabled() {
191+ log := zerowrap.FromCtx(ctx)
192+ log.Debug().Msg("merge issue closing disabled")
193+ return
194+ }
195+ for _, n := range closes {
196+ err := l.close(ctx, p, repo, mr.Number, n)
197+ if err != nil {
198+ log := zerowrap.FromCtx(ctx)
199+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
200+ }
201+ }
202+}
203+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
204+ for range maxWriteRetries {
205+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
206+ if err != nil {
207+ return err
208+ }
209+ if !issue.IsOpen() {
210+ return nil
211+ }
212+ issue.State = domain.IssueStateClosed
213+ issue.ClosedByMergeRequest = mr
214+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
215+ err = l.cfg.Issues.Update(ctx, issue)
216+ if errors.Is(err, domain.ErrConflict) {
217+ continue
218+ }
219+ if err != nil {
220+ return err
221+ }
222+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
223+ log := zerowrap.FromCtx(ctx)
224+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
225+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
226+ return nil
227+ }
228+ return domain.ErrConflict
229+}
@@ -0,0 +1,229 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/bnema/zerowrap"
15+)
16+
17+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
18+const MaxIssueLinksPerIssue = 100
19+const maxLinkedMRsPerPush = 50
20+
21+type IssueLinksConfig struct {
22+ Meta ports.RepoMetaStore
23+ Issues ports.IssueStore
24+ MRs ports.MergeRequestStore
25+ Links ports.IssueLinkStore
26+ Git ports.MergeGit
27+ Bus ports.EventBus
28+ Clock ports.Clock
29+}
30+
31+// IssueLinks maintains the derived index and applies closing references on merge.
32+type IssueLinks struct{ cfg IssueLinksConfig }
33+
34+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
35+
36+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks { return &IssueLinks{cfg: cfg} }
37+
38+// Reindex replaces links from the current texts and bounded commit range.
39+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
40+ _, err := l.reindex(ctx, repo, number)
41+ return err
42+}
43+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
44+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
45+ if err != nil {
46+ return nil, err
47+ }
48+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
49+ if err != nil {
50+ return nil, err
51+ }
52+ var texts, messages []string
53+ for _, note := range notes {
54+ if !note.Deleted {
55+ texts = append(texts, note.Text.String())
56+ }
57+ }
58+ if mr.IsOpen() {
59+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
60+ if errors.Is(err, domain.ErrNotFound) {
61+ mr.HeadID = domain.ObjectID{}
62+ } else if err != nil {
63+ return nil, err
64+ }
65+ }
66+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
67+ if err != nil {
68+ return nil, err
69+ }
70+ has := !mr.HeadID.IsZero()
71+ if has && !mr.IsOpen() {
72+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
73+ if err != nil {
74+ return nil, err
75+ }
76+ }
77+ if has {
78+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
79+ if err != nil {
80+ return nil, err
81+ }
82+ if more {
83+ log := zerowrap.FromCtx(ctx)
84+ log.Debug().Msg("issue link commit range truncated")
85+ }
86+ for _, commit := range commits {
87+ messages = append(messages, commit.Message)
88+ }
89+ }
90+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
91+ var mentions, closes []int64
92+ for _, n := range refs.Mentions {
93+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
94+ if errors.Is(err, domain.ErrNotFound) {
95+ continue
96+ }
97+ if err != nil {
98+ return nil, err
99+ }
100+ mentions = append(mentions, n)
101+ if slices.Contains(refs.Closes, n) {
102+ closes = append(closes, n)
103+ }
104+ }
105+ for range maxWriteRetries {
106+ added, _, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
107+ if errors.Is(err, domain.ErrConflict) {
108+ continue
109+ }
110+ if err != nil {
111+ return nil, err
112+ }
113+ for _, link := range added {
114+ l.notify(ctx, repo, link)
115+ }
116+ return closes, nil
117+ }
118+ return nil, domain.ErrConflict
119+}
120+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
121+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
122+}
123+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
124+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
125+}
126+func linkWarning(ctx context.Context, err error) {
127+ if err != nil {
128+ log := zerowrap.FromCtx(ctx)
129+ log.Warn().Err(err).Msg("issue links not updated")
130+ }
131+}
132+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
133+ ctx, cancel := linkContext(ctx)
134+ defer cancel()
135+ linkWarning(ctx, l.Reindex(ctx, repo, number))
136+}
137+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
138+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
139+ linkWarning(ctx, err)
140+ if err == nil {
141+ for _, link := range links {
142+ l.notify(ctx, repo, link)
143+ }
144+ }
145+}
146+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
147+ ctx, cancel := linkContext(ctx)
148+ defer cancel()
149+ l.settled(ctx, repo, mr)
150+}
151+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
152+ ctx, cancel := linkContext(ctx)
153+ defer cancel()
154+ var branches []string
155+ for _, up := range updates {
156+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
157+ branches = append(branches, b)
158+ }
159+ }
160+ if len(branches) == 0 {
161+ return
162+ }
163+ repo, err := l.cfg.Meta.Get(ctx, name)
164+ if err != nil {
165+ linkWarning(ctx, err)
166+ return
167+ }
168+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
169+ if err != nil {
170+ linkWarning(ctx, err)
171+ return
172+ }
173+ if truncated {
174+ log := zerowrap.FromCtx(ctx)
175+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
176+ }
177+ for _, n := range numbers {
178+ linkWarning(ctx, l.Reindex(ctx, repo, n))
179+ }
180+}
181+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
182+ ctx, cancel := linkContext(ctx)
183+ defer cancel()
184+ defer l.settled(ctx, repo, mr)
185+ closes, err := l.reindex(ctx, repo, mr.Number)
186+ if err != nil {
187+ linkWarning(ctx, err)
188+ return
189+ }
190+ if !repo.Settings.IssuesEnabled() {
191+ log := zerowrap.FromCtx(ctx)
192+ log.Debug().Msg("merge issue closing disabled")
193+ return
194+ }
195+ for _, n := range closes {
196+ err := l.close(ctx, p, repo, mr.Number, n)
197+ if err != nil {
198+ log := zerowrap.FromCtx(ctx)
199+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
200+ }
201+ }
202+}
203+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
204+ for range maxWriteRetries {
205+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
206+ if err != nil {
207+ return err
208+ }
209+ if !issue.IsOpen() {
210+ return nil
211+ }
212+ issue.State = domain.IssueStateClosed
213+ issue.ClosedByMergeRequest = mr
214+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
215+ err = l.cfg.Issues.Update(ctx, issue)
216+ if errors.Is(err, domain.ErrConflict) {
217+ continue
218+ }
219+ if err != nil {
220+ return err
221+ }
222+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
223+ log := zerowrap.FromCtx(ctx)
224+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
225+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
226+ return nil
227+ }
228+ return domain.ErrConflict
229+}
A internal/usecase/issue_links_test.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+ "bnema.dev/ermite/internal/usecase"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
17+ t.Helper()
18+ r := newMRRig(t)
19+ r.seed()
20+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
21+ require.NoError(t, err)
22+ 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{}})
23+ 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})
24+ 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{}})
25+ return r, links, issues
26+}
27+
28+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
29+ r, links, issues := linkedRig(t)
30+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
31+ require.NoError(t, err)
32+ r.feature("feat")
33+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
34+ require.NoError(t, err)
35+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
36+ require.NoError(t, err)
37+ require.Len(t, got.References, 1)
38+ assert.Equal(t, mr.Title, got.References[0].Title)
39+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
40+ require.NoError(t, err)
41+ 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)})
42+ require.NoError(t, err)
43+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
44+ require.NoError(t, err)
45+ require.Len(t, got.References, 1)
46+ assert.False(t, got.References[0].Closes)
47+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
48+ require.NoError(t, err)
49+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
50+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
51+ require.NoError(t, err)
52+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
53+ require.NoError(t, err)
54+ assert.Empty(t, got.References)
55+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
56+ r.push("feat")
57+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
58+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
59+ require.NoError(t, err)
60+ require.Len(t, got.References, 1)
61+ assert.True(t, got.References[0].Closes)
62+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
63+ require.NoError(t, err)
64+ require.Len(t, stored, 2)
65+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
68+ require.NoError(t, err)
69+ assert.True(t, got.Issue.IsOpen())
70+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
71+}
72+
73+func TestIssueLinksMergeClosing(t *testing.T) {
74+ for _, mode := range []string{"open", "off", "closed"} {
75+ t.Run(mode, func(t *testing.T) {
76+ r, _, issues := linkedRig(t)
77+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
78+ require.NoError(t, err)
79+ if mode == "closed" {
80+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
81+ require.NoError(t, err)
82+ }
83+ r.feature("feat")
84+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
85+ r.push("feat")
86+ mr := r.open("feat")
87+ if mode == "off" {
88+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
89+ require.NoError(t, err)
90+ }
91+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
92+ require.NoError(t, err)
93+ r.drain()
94+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
95+ require.NoError(t, err)
96+ events := r.drain()
97+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
98+ require.NoError(t, err)
99+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
100+ require.NoError(t, err)
101+ require.Len(t, refs, 1)
102+ assert.True(t, refs[0].Closes)
103+ closedEvents, referenced := 0, 0
104+ for _, ev := range events {
105+ switch ev.(type) {
106+ case domain.IssueClosed:
107+ closedEvents++
108+ case domain.IssueReferenced:
109+ referenced++
110+ }
111+ }
112+ assert.Positive(t, referenced)
113+ if mode == "open" {
114+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
115+ assert.False(t, got.IsOpen())
116+ assert.Equal(t, 1, closedEvents)
117+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
118+ require.NoError(t, err)
119+ assert.Zero(t, reopened.ClosedByMergeRequest)
120+ } else {
121+ assert.Zero(t, got.ClosedByMergeRequest)
122+ assert.Equal(t, 0, closedEvents)
123+ if mode == "closed" {
124+ assert.Equal(t, issue, got)
125+ } else {
126+ assert.True(t, got.IsOpen())
127+ }
128+ }
129+ })
130+ }
131+}
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+ "bnema.dev/ermite/internal/usecase"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
17+ t.Helper()
18+ r := newMRRig(t)
19+ r.seed()
20+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
21+ require.NoError(t, err)
22+ 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{}})
23+ 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})
24+ 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{}})
25+ return r, links, issues
26+}
27+
28+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
29+ r, links, issues := linkedRig(t)
30+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
31+ require.NoError(t, err)
32+ r.feature("feat")
33+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
34+ require.NoError(t, err)
35+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
36+ require.NoError(t, err)
37+ require.Len(t, got.References, 1)
38+ assert.Equal(t, mr.Title, got.References[0].Title)
39+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
40+ require.NoError(t, err)
41+ 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)})
42+ require.NoError(t, err)
43+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
44+ require.NoError(t, err)
45+ require.Len(t, got.References, 1)
46+ assert.False(t, got.References[0].Closes)
47+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
48+ require.NoError(t, err)
49+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
50+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
51+ require.NoError(t, err)
52+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
53+ require.NoError(t, err)
54+ assert.Empty(t, got.References)
55+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
56+ r.push("feat")
57+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
58+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
59+ require.NoError(t, err)
60+ require.Len(t, got.References, 1)
61+ assert.True(t, got.References[0].Closes)
62+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
63+ require.NoError(t, err)
64+ require.Len(t, stored, 2)
65+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
68+ require.NoError(t, err)
69+ assert.True(t, got.Issue.IsOpen())
70+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
71+}
72+
73+func TestIssueLinksMergeClosing(t *testing.T) {
74+ for _, mode := range []string{"open", "off", "closed"} {
75+ t.Run(mode, func(t *testing.T) {
76+ r, _, issues := linkedRig(t)
77+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
78+ require.NoError(t, err)
79+ if mode == "closed" {
80+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
81+ require.NoError(t, err)
82+ }
83+ r.feature("feat")
84+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
85+ r.push("feat")
86+ mr := r.open("feat")
87+ if mode == "off" {
88+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
89+ require.NoError(t, err)
90+ }
91+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
92+ require.NoError(t, err)
93+ r.drain()
94+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
95+ require.NoError(t, err)
96+ events := r.drain()
97+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
98+ require.NoError(t, err)
99+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
100+ require.NoError(t, err)
101+ require.Len(t, refs, 1)
102+ assert.True(t, refs[0].Closes)
103+ closedEvents, referenced := 0, 0
104+ for _, ev := range events {
105+ switch ev.(type) {
106+ case domain.IssueClosed:
107+ closedEvents++
108+ case domain.IssueReferenced:
109+ referenced++
110+ }
111+ }
112+ assert.Positive(t, referenced)
113+ if mode == "open" {
114+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
115+ assert.False(t, got.IsOpen())
116+ assert.Equal(t, 1, closedEvents)
117+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
118+ require.NoError(t, err)
119+ assert.Zero(t, reopened.ClosedByMergeRequest)
120+ } else {
121+ assert.Zero(t, got.ClosedByMergeRequest)
122+ assert.Equal(t, 0, closedEvents)
123+ if mode == "closed" {
124+ assert.Equal(t, issue, got)
125+ } else {
126+ assert.True(t, got.IsOpen())
127+ }
128+ }
129+ })
130+ }
131+}
M internal/usecase/issues.go +22 -2
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
2626 // issue:write on it, which the branch prefixes of a token do not restrict:
2727 // an issue writes no ref.
2828 type Issues struct {
29+ links ports.IssueLinkStore
2930 meta ports.RepoMetaStore
3031 store ports.IssueStore
3132 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
4041
4142 // IssuesConfig holds what the issue use cases need. Every field is required.
4243 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
4345 Meta ports.RepoMetaStore
4446 Store ports.IssueStore
4547 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
5052
5153 // NewIssues builds the issue use cases.
5254 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
5456 }
5557
5658 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159161 if err != nil {
160162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil
164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163182 }
164183
165184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251270 if issue.State != from {
252271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253272 }
273+ issue.ClosedByMergeRequest = 0
254274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255275 err = i.store.Update(ctx, issue)
256276 if errors.Is(err, domain.ErrConflict) {
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
26 // issue:write on it, which the branch prefixes of a token do not restrict:26 // issue:write on it, which the branch prefixes of a token do not restrict:
27 // an issue writes no ref.27 // an issue writes no ref.
28 type Issues struct {28 type Issues struct {
29+ links ports.IssueLinkStore
29 meta ports.RepoMetaStore30 meta ports.RepoMetaStore
30 store ports.IssueStore31 store ports.IssueStore
31 mrs ports.MergeRequestStore32 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
40 41
41 // IssuesConfig holds what the issue use cases need. Every field is required.42 // IssuesConfig holds what the issue use cases need. Every field is required.
42 type IssuesConfig struct {43 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
43 Meta ports.RepoMetaStore45 Meta ports.RepoMetaStore
44 Store ports.IssueStore46 Store ports.IssueStore
45 // MergeRequests tells which numbers name a merge request (Kind).47 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
50 52
51 // NewIssues builds the issue use cases.53 // NewIssues builds the issue use cases.
52 func NewIssues(cfg IssuesConfig) *Issues {54 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54 }56 }
55 57
56 // enabled looks the repository up and hides it when issues are off.58 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159 if err != nil {161 if err != nil {
160 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161 }163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163 }182 }
164 183
165 // AddComment adds a comment and publishes IssueCommented. The principal needs184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251 if issue.State != from {270 if issue.State != from {
252 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253 }272 }
273+ issue.ClosedByMergeRequest = 0
254 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255 err = i.store.Update(ctx, issue)275 err = i.store.Update(ctx, issue)
256 if errors.Is(err, domain.ErrConflict) {276 if errors.Is(err, domain.ErrConflict) {
M internal/usecase/mergerequests.go +30 -3
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
4343 // merging mr:merge, on the repository (see scopeAccess), and that scope also
4444 // makes a private repository visible for the action (see writable).
4545 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
4647 meta ports.RepoMetaStore
4748 store ports.MergeRequestStore
4849 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
7778 // MergeRequestsConfig holds what the merge request use cases need. Every field
7879 // is required.
7980 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
8082 Meta ports.RepoMetaStore
8183 Store ports.MergeRequestStore
8284 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111113 // NewMergeRequests builds the merge request use cases.
112114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116118 locks: make(map[domain.RepoName]chan struct{}),
117119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288290 }
289291 recordActivity(ctx, m.meta, repo.Name, now)
290292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291296 if m.mergeab != nil {
292297 m.mergeab.OnOpened(ctx, mr)
293298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430435 }
431436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433441 return m.withHead(ctx, mr)
434442 }
435443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647655 // the head; both are zero when the target is gone or shares no history with
648656 // the head. A merged or closed request keeps the base it stored for both.
649657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650662 if !mr.IsOpen() {
651663 return mr.BaseID, mr.BaseID, nil
652664 }
653665 if mr.HeadID.IsZero() {
654666 return domain.ObjectID{}, domain.ObjectID{}, nil
655667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657672 if err != nil || tip.IsZero() {
658673 return domain.ObjectID{}, domain.ObjectID{}, err
659674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661676 if errors.Is(err, domain.ErrNotFound) {
662677 return tip, domain.ObjectID{}, nil
663678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777792 m.bus.Publish(ctx, domain.CommentAdded{
778793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780798 return c, nil
781799 }
782800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915936 if m.pulls != nil {
916937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11681189 // never returned. mr is the merged request, tip the old target and merged the
11691190 // new one.
11701191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
11711195 name := repo.Name
11721196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
11731197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1453,6 +1477,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
14531477 } else {
14541478 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
14551479 }
1480+ if m.links != nil {
1481+ m.links.OnMergeRequestChanged(ctx, repo, number)
1482+ }
14561483 return c, nil
14571484 }
14581485 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
43 // merging mr:merge, on the repository (see scopeAccess), and that scope also43 // merging mr:merge, on the repository (see scopeAccess), and that scope also
44 // makes a private repository visible for the action (see writable).44 // makes a private repository visible for the action (see writable).
45 type MergeRequests struct {45 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
46 meta ports.RepoMetaStore47 meta ports.RepoMetaStore
47 store ports.MergeRequestStore48 store ports.MergeRequestStore
48 repos ports.GitRepos49 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
77 // MergeRequestsConfig holds what the merge request use cases need. Every field78 // MergeRequestsConfig holds what the merge request use cases need. Every field
78 // is required.79 // is required.
79 type MergeRequestsConfig struct {80 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
80 Meta ports.RepoMetaStore82 Meta ports.RepoMetaStore
81 Store ports.MergeRequestStore83 Store ports.MergeRequestStore
82 // Repos gives the default branch of a repository.84 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111 // NewMergeRequests builds the merge request use cases.113 // NewMergeRequests builds the merge request use cases.
112 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113 return &MergeRequests{115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116 locks: make(map[domain.RepoName]chan struct{}),118 locks: make(map[domain.RepoName]chan struct{}),
117 }119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288 }290 }
289 recordActivity(ctx, m.meta, repo.Name, now)291 recordActivity(ctx, m.meta, repo.Name, now)
290 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291 if m.mergeab != nil {296 if m.mergeab != nil {
292 m.mergeab.OnOpened(ctx, mr)297 m.mergeab.OnOpened(ctx, mr)
293 }298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430 }435 }
431 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433 return m.withHead(ctx, mr)441 return m.withHead(ctx, mr)
434 }442 }
435 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647 // the head; both are zero when the target is gone or shares no history with655 // the head; both are zero when the target is gone or shares no history with
648 // the head. A merged or closed request keeps the base it stored for both.656 // the head. A merged or closed request keeps the base it stored for both.
649 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650 if !mr.IsOpen() {662 if !mr.IsOpen() {
651 return mr.BaseID, mr.BaseID, nil663 return mr.BaseID, mr.BaseID, nil
652 }664 }
653 if mr.HeadID.IsZero() {665 if mr.HeadID.IsZero() {
654 return domain.ObjectID{}, domain.ObjectID{}, nil666 return domain.ObjectID{}, domain.ObjectID{}, nil
655 }667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657 if err != nil || tip.IsZero() {672 if err != nil || tip.IsZero() {
658 return domain.ObjectID{}, domain.ObjectID{}, err673 return domain.ObjectID{}, domain.ObjectID{}, err
659 }674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661 if errors.Is(err, domain.ErrNotFound) {676 if errors.Is(err, domain.ErrNotFound) {
662 return tip, domain.ObjectID{}, nil677 return tip, domain.ObjectID{}, nil
663 }678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777 m.bus.Publish(ctx, domain.CommentAdded{792 m.bus.Publish(ctx, domain.CommentAdded{
778 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779 })794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780 return c, nil798 return c, nil
781 }799 }
782 800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915 if m.pulls != nil {936 if m.pulls != nil {
916 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917 }938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1168 // never returned. mr is the merged request, tip the old target and merged the1189 // never returned. mr is the merged request, tip the old target and merged the
1169 // new one.1190 // new one.
1170 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {1191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
1171 name := repo.Name1195 name := repo.Name
1172 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}1196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1173 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {1197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1453,6 +1477,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
1453 } else {1477 } else {
1454 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1478 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1455 }1479 }
1480+ if m.links != nil {
1481+ m.links.OnMergeRequestChanged(ctx, repo, number)
1482+ }
1456 return c, nil1483 return c, nil
1457 }1484 }
1458 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)1485 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
M internal/usecase/pushes.go +7 -0
@@ -17,6 +17,7 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20+ links ports.IssueLinkTrigger
2021 mirror ports.MirrorTrigger // nil: no outbound mirror
2122 meta ports.RepoMetaStore
2223 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4445 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4546 }
4647
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
4751 // WithMirror makes Record notify outbound mirrors. Call before the first push.
4852 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
4953 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265269 if u.mergeab != nil {
266270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268275 u.bus.Publish(ctx, domain.PushEvent{
269276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,6 +17,7 @@ import (
17 17
18 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
19 type Pushes struct {19 type Pushes struct {
20+ links ports.IssueLinkTrigger
20 mirror ports.MirrorTrigger // nil: no outbound mirror21 mirror ports.MirrorTrigger // nil: no outbound mirror
21 meta ports.RepoMetaStore22 meta ports.RepoMetaStore
22 repos *Repos23 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
44 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}45 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
45 }46 }
46 47
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
47 // WithMirror makes Record notify outbound mirrors. Call before the first push.51 // WithMirror makes Record notify outbound mirrors. Call before the first push.
48 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {52 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
49 u.mirror = t53 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265 if u.mergeab != nil {269 if u.mergeab != nil {
266 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267 }271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268 u.bus.Publish(ctx, domain.PushEvent{275 u.bus.Publish(ctx, domain.PushEvent{
269 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close