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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 363 | 363 | Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock, | |
| 364 | 364 | }) | |
| 365 | 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 | 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 | 370 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 369 | 371 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | |
| 370 | 372 | }) | |
| 371 | 373 | f.Mergeability.Start() | |
| 372 | 374 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | |
| 373 | 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 | 378 | f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{ | |
| 377 | 379 | 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 { | |||
| 242 | 242 | 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 | 254 | type IssueDetail struct { | |
| 247 | - | Issue domain.Issue | |
| 255 | + | // References are ascending by number, at most 100. | |
| 256 | + | References []IssueReference | |
| 257 | + | Issue domain.Issue | |
| 248 | 258 | // Comments are oldest first. | |
| 249 | 259 | Comments []domain.IssueComment | |
| 250 | 260 | } | |
| @@ -242,9 +242,19 @@ type CreateIssueParams struct { | |||||
| 242 | Body string | 242 | 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.Issue | 255 | + | // 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.IssueComment | 259 | 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 { | |||
| 552 | 552 | type Clock interface { | |
| 553 | 553 | 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 | + | } | |
| @@ -552,3 +552,11 @@ type IssueLinkStore interface { | |||||
| 552 | type Clock interface { | 552 | type Clock interface { | ||
| 553 | Now() time.Time | 553 | 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 | |||
| 26 | 26 | // issue:write on it, which the branch prefixes of a token do not restrict: | |
| 27 | 27 | // an issue writes no ref. | |
| 28 | 28 | type Issues struct { | |
| 29 | + | links ports.IssueLinkStore | |
| 29 | 30 | meta ports.RepoMetaStore | |
| 30 | 31 | store ports.IssueStore | |
| 31 | 32 | mrs ports.MergeRequestStore | |
| @@ -40,6 +41,7 @@ var ( | |||
| 40 | 41 | ||
| 41 | 42 | // IssuesConfig holds what the issue use cases need. Every field is required. | |
| 42 | 43 | type IssuesConfig struct { | |
| 44 | + | Links ports.IssueLinkStore | |
| 43 | 45 | Meta ports.RepoMetaStore | |
| 44 | 46 | Store ports.IssueStore | |
| 45 | 47 | // MergeRequests tells which numbers name a merge request (Kind). | |
| @@ -50,7 +52,7 @@ type IssuesConfig struct { | |||
| 50 | 52 | ||
| 51 | 53 | // NewIssues builds the issue use cases. | |
| 52 | 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 | 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 | 161 | if err != nil { | |
| 160 | 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}, 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 | |
| 163 | 182 | } | |
| 164 | 183 | ||
| 165 | 184 | // 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 | 270 | if issue.State != from { | |
| 252 | 271 | return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State) | |
| 253 | 272 | } | |
| 273 | + | issue.ClosedByMergeRequest = 0 | |
| 254 | 274 | issue.State, issue.UpdatedAt = to, i.clock.Now().UTC() | |
| 255 | 275 | err = i.store.Update(ctx, issue) | |
| 256 | 276 | 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.RepoMetaStore | 30 | meta ports.RepoMetaStore | ||
| 30 | store ports.IssueStore | 31 | store ports.IssueStore | ||
| 31 | mrs ports.MergeRequestStore | 32 | 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.RepoMetaStore | 45 | Meta ports.RepoMetaStore | ||
| 44 | Store ports.IssueStore | 46 | 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}, 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 | |||
| 163 | } | 182 | } | ||
| 164 | 183 | ||||
| 165 | // AddComment adds a comment and publishes IssueCommented. The principal needs | 184 | // 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:" | |||
| 43 | 43 | // merging mr:merge, on the repository (see scopeAccess), and that scope also | |
| 44 | 44 | // makes a private repository visible for the action (see writable). | |
| 45 | 45 | type MergeRequests struct { | |
| 46 | + | links ports.IssueLinkTrigger | |
| 46 | 47 | meta ports.RepoMetaStore | |
| 47 | 48 | store ports.MergeRequestStore | |
| 48 | 49 | repos ports.GitRepos | |
| @@ -77,6 +78,7 @@ var ( | |||
| 77 | 78 | // MergeRequestsConfig holds what the merge request use cases need. Every field | |
| 78 | 79 | // is required. | |
| 79 | 80 | type MergeRequestsConfig struct { | |
| 81 | + | Links ports.IssueLinkTrigger // Optional derived issue links. | |
| 80 | 82 | Meta ports.RepoMetaStore | |
| 81 | 83 | Store ports.MergeRequestStore | |
| 82 | 84 | // Repos gives the default branch of a repository. | |
| @@ -111,7 +113,7 @@ type MergeRequestsConfig struct { | |||
| 111 | 113 | // NewMergeRequests builds the merge request use cases. | |
| 112 | 114 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |
| 113 | 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 | 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 | 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 | 291 | recordActivity(ctx, m.meta, repo.Name, now) | |
| 290 | 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 | 296 | if m.mergeab != nil { | |
| 292 | 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 | 436 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 432 | 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 | 441 | return m.withHead(ctx, mr) | |
| 434 | 442 | } | |
| 435 | 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 | 655 | // the head; both are zero when the target is gone or shares no history with | |
| 648 | 656 | // the head. A merged or closed request keeps the base it stored for both. | |
| 649 | 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 | 662 | if !mr.IsOpen() { | |
| 651 | 663 | return mr.BaseID, mr.BaseID, nil | |
| 652 | 664 | } | |
| 653 | 665 | if mr.HeadID.IsZero() { | |
| 654 | 666 | 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 | 672 | if err != nil || tip.IsZero() { | |
| 658 | 673 | 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 | 676 | if errors.Is(err, domain.ErrNotFound) { | |
| 662 | 677 | return tip, domain.ObjectID{}, nil | |
| 663 | 678 | } | |
| @@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||
| 777 | 792 | m.bus.Publish(ctx, domain.CommentAdded{ | |
| 778 | 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 | 798 | return c, nil | |
| 781 | 799 | } | |
| 782 | 800 | ||
| @@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 912 | 930 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 913 | 931 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 914 | 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 | 936 | if m.pulls != nil { | |
| 916 | 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 | 1189 | // never returned. mr is the merged request, tip the old target and merged the | |
| 1169 | 1190 | // new one. | |
| 1170 | 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 | 1195 | name := repo.Name | |
| 1172 | 1196 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | |
| 1173 | 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 | 1477 | } else { | |
| 1454 | 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 | 1483 | return c, nil | |
| 1457 | 1484 | } | |
| 1458 | 1485 | 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 also | 43 | // 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.RepoMetaStore | 47 | meta ports.RepoMetaStore | ||
| 47 | store ports.MergeRequestStore | 48 | store ports.MergeRequestStore | ||
| 48 | repos ports.GitRepos | 49 | repos ports.GitRepos | ||
| @@ -77,6 +78,7 @@ var ( | |||||
| 77 | // MergeRequestsConfig holds what the merge request use cases need. Every field | 78 | // 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.RepoMetaStore | 82 | Meta ports.RepoMetaStore | ||
| 81 | Store ports.MergeRequestStore | 83 | 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 with | 655 | // 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, nil | 663 | return mr.BaseID, mr.BaseID, nil | ||
| 652 | } | 664 | } | ||
| 653 | if mr.HeadID.IsZero() { | 665 | if mr.HeadID.IsZero() { | ||
| 654 | return domain.ObjectID{}, domain.ObjectID{}, nil | 666 | 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{}, err | 673 | 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{}, nil | 677 | 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, nil | 798 | 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 the | 1189 | // 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.Name | 1195 | 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, nil | 1483 | 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 ( | |||
| 17 | 17 | ||
| 18 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | type Pushes struct { | |
| 20 | + | links ports.IssueLinkTrigger | |
| 20 | 21 | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 21 | 22 | meta ports.RepoMetaStore | |
| 22 | 23 | repos *Repos | |
| @@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 44 | 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 | 51 | // WithMirror makes Record notify outbound mirrors. Call before the first push. | |
| 48 | 52 | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | |
| 49 | 53 | u.mirror = t | |
| @@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 265 | 269 | if u.mergeab != nil { | |
| 266 | 270 | u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only | |
| 267 | 271 | } | |
| 272 | + | if u.links != nil { | |
| 273 | + | u.links.OnBranchesMoved(ctx, name, updates) | |
| 274 | + | } | |
| 268 | 275 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 269 | 276 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 270 | 277 | 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 mirror | 21 | mirror ports.MirrorTrigger // nil: no outbound mirror | ||
| 21 | meta ports.RepoMetaStore | 22 | meta ports.RepoMetaStore | ||
| 22 | repos *Repos | 23 | 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 = t | 53 | 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 only | 270 | 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, | ||