feat(mr): enable, cancel and evaluate merge request auto-merge
- commit
- 1314a0d9ddd6a1adedb5a4c6be23eb30027db978
- author
- brice · · 2026-10-20 16:04 UTC
- parent
- 9795e63
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +1189 -50
A internal/usecase/automerge.go +382 -0
| @@ -0,0 +1,382 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // AutoMergeConfig holds dependencies of the asynchronous merge evaluator. | |
| 19 | + | type AutoMergeConfig struct { | |
| 20 | + | MRs *MergeRequests | |
| 21 | + | Store ports.MergeRequestStore | |
| 22 | + | Meta ports.RepoMetaStore | |
| 23 | + | Git ports.MergeGit | |
| 24 | + | Checks ports.CIChecksReader | |
| 25 | + | Activity ports.CIActivity | |
| 26 | + | Tokens ports.TokenStore | |
| 27 | + | Bus ports.EventBus | |
| 28 | + | Clock ports.Clock | |
| 29 | + | After func(time.Duration) <-chan time.Time | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // AutoMerge serializes evaluation within each repository. Triggers only enqueue | |
| 33 | + | // work, including when invoked while CI or merge-request locks are held. | |
| 34 | + | type AutoMerge struct { | |
| 35 | + | cfg AutoMergeConfig | |
| 36 | + | ctx context.Context | |
| 37 | + | cancel context.CancelFunc | |
| 38 | + | wg sync.WaitGroup | |
| 39 | + | mu sync.Mutex | |
| 40 | + | dirty map[domain.RepoName]bool | |
| 41 | + | order []domain.RepoName | |
| 42 | + | running, again map[domain.RepoName]bool | |
| 43 | + | wake chan struct{} | |
| 44 | + | retryMu sync.Mutex | |
| 45 | + | retries map[domain.RepoName]int | |
| 46 | + | scheduled map[domain.RepoName]bool | |
| 47 | + | heap []retryItem | |
| 48 | + | retryKick chan struct{} | |
| 49 | + | closed bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | var _ ports.AutoMergeTrigger = (*AutoMerge)(nil) | |
| 53 | + | ||
| 54 | + | // NewAutoMerge builds an evaluator; call Start once, and Close before MRs.Wait. | |
| 55 | + | func NewAutoMerge(ctx context.Context, cfg AutoMergeConfig) *AutoMerge { | |
| 56 | + | if cfg.After == nil { | |
| 57 | + | cfg.After = time.After | |
| 58 | + | } | |
| 59 | + | ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 60 | + | return &AutoMerge{cfg: cfg, ctx: ctx, cancel: cancel, | |
| 61 | + | dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1), | |
| 62 | + | retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Start starts workers and discovers repositories, retrying failed discovery. | |
| 66 | + | func (s *AutoMerge) Start() { | |
| 67 | + | s.wg.Add(2 + defaultMergeabilityWorkers) | |
| 68 | + | go s.scheduler() | |
| 69 | + | go func() { | |
| 70 | + | defer s.wg.Done() | |
| 71 | + | for attempt := 0; attempt <= mergeabilityRetries; attempt++ { | |
| 72 | + | repos, err := s.cfg.Store.ListOpenAll(s.ctx) | |
| 73 | + | if err != nil && s.ctx.Err() == nil { | |
| 74 | + | ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("auto-merge: list the repositories at startup") | |
| 75 | + | } | |
| 76 | + | if err == nil { | |
| 77 | + | for _, repo := range repos { | |
| 78 | + | if repo.AutoMerge != nil { | |
| 79 | + | s.markDirty(repo.Repo) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | if attempt == mergeabilityRetries { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | select { | |
| 88 | + | case <-s.ctx.Done(): | |
| 89 | + | return | |
| 90 | + | case <-s.cfg.After(mergeBackoff(attempt + 1)): | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | }() | |
| 94 | + | for range defaultMergeabilityWorkers { | |
| 95 | + | go s.loop() | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Close cancels workers and pending retry waits, then waits for them. | |
| 100 | + | func (s *AutoMerge) Close(_ context.Context) { | |
| 101 | + | s.retryMu.Lock() | |
| 102 | + | s.closed = true | |
| 103 | + | s.retryMu.Unlock() | |
| 104 | + | s.cancel() | |
| 105 | + | s.wg.Wait() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (s *AutoMerge) signal() { | |
| 109 | + | select { | |
| 110 | + | case s.wake <- struct{}{}: | |
| 111 | + | default: | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (s *AutoMerge) markDirty(repo domain.RepoName) { | |
| 116 | + | s.mu.Lock() | |
| 117 | + | if s.running[repo] { | |
| 118 | + | s.again[repo] = true | |
| 119 | + | } else if !s.dirty[repo] { | |
| 120 | + | s.dirty[repo] = true | |
| 121 | + | s.order = append(s.order, repo) | |
| 122 | + | } | |
| 123 | + | s.mu.Unlock() | |
| 124 | + | s.signal() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (s *AutoMerge) next() (domain.RepoName, bool) { | |
| 128 | + | s.mu.Lock() | |
| 129 | + | defer s.mu.Unlock() | |
| 130 | + | if len(s.order) == 0 { | |
| 131 | + | return domain.RepoName{}, false | |
| 132 | + | } | |
| 133 | + | repo := s.order[0] | |
| 134 | + | s.order = s.order[1:] | |
| 135 | + | delete(s.dirty, repo) | |
| 136 | + | s.running[repo] = true | |
| 137 | + | if len(s.order) > 0 { | |
| 138 | + | s.signal() | |
| 139 | + | } | |
| 140 | + | return repo, true | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (s *AutoMerge) loop() { | |
| 144 | + | defer s.wg.Done() | |
| 145 | + | for s.ctx.Err() == nil { | |
| 146 | + | repo, found := s.next() | |
| 147 | + | if !found { | |
| 148 | + | select { | |
| 149 | + | case <-s.ctx.Done(): | |
| 150 | + | return | |
| 151 | + | case <-s.wake: | |
| 152 | + | } | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | if s.evaluate(repo) { | |
| 156 | + | s.retryDone(repo) | |
| 157 | + | } else { | |
| 158 | + | s.retry(repo) | |
| 159 | + | } | |
| 160 | + | s.mu.Lock() | |
| 161 | + | delete(s.running, repo) | |
| 162 | + | if s.again[repo] { | |
| 163 | + | delete(s.again, repo) | |
| 164 | + | s.dirty[repo] = true | |
| 165 | + | s.order = append(s.order, repo) | |
| 166 | + | s.signal() | |
| 167 | + | } | |
| 168 | + | s.mu.Unlock() | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // OnRepoChanged marks and wakes only; it never merges on the caller's stack. | |
| 173 | + | func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) } | |
| 174 | + | ||
| 175 | + | func (s *AutoMerge) evaluate(repo domain.RepoName) bool { | |
| 176 | + | err := s.evaluateRepo(repo) | |
| 177 | + | if err != nil && s.ctx.Err() == nil { | |
| 178 | + | ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository") | |
| 179 | + | } | |
| 180 | + | return err == nil || s.ctx.Err() != nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error { | |
| 184 | + | ctx := s.ctx | |
| 185 | + | meta, err := s.cfg.Meta.Get(ctx, repo) | |
| 186 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | if err != nil { | |
| 190 | + | return err | |
| 191 | + | } | |
| 192 | + | mrs, err := s.cfg.Store.ListOpen(ctx, repo) | |
| 193 | + | if err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | var failures []error | |
| 197 | + | for _, mr := range mrs { | |
| 198 | + | if ctx.Err() != nil { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | if mr.AutoMerge == nil { | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | if err := s.evaluateMR(ctx, meta, mr); err != nil { | |
| 205 | + | failures = append(failures, err) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | return errors.Join(failures...) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error { | |
| 212 | + | intent := mr.AutoMerge | |
| 213 | + | settings := repo.EffectiveSettings() | |
| 214 | + | cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) } | |
| 215 | + | if !settings.AllowAutoMerge { | |
| 216 | + | return cancel("auto-merge was turned off in the repository settings") | |
| 217 | + | } | |
| 218 | + | if !settings.Allows(intent.Mode) { | |
| 219 | + | return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode)) | |
| 220 | + | } | |
| 221 | + | head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source) | |
| 222 | + | if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) { | |
| 223 | + | return cancel("the source branch was deleted") | |
| 224 | + | } | |
| 225 | + | if err != nil { | |
| 226 | + | return err | |
| 227 | + | } | |
| 228 | + | if head != intent.HeadID { | |
| 229 | + | return cancel("the branch moved to " + head.String()[:7]) | |
| 230 | + | } | |
| 231 | + | p, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now()) | |
| 232 | + | if err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | if !valid || !p.Allows(domain.ScopeMRMerge, mr.Repo) || !p.AllowsRef("refs/heads/"+mr.Target) { | |
| 236 | + | return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target) | |
| 237 | + | } | |
| 238 | + | if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) { | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String()) | |
| 242 | + | if err != nil { | |
| 243 | + | return err | |
| 244 | + | } | |
| 245 | + | passed := false | |
| 246 | + | for _, check := range checks { | |
| 247 | + | if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess { | |
| 248 | + | passed = true | |
| 249 | + | break | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | if !passed { | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | _, err = s.cfg.MRs.mergeAs(ctx, p, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: true, intent: intent}) | |
| 256 | + | switch { | |
| 257 | + | case err == nil, errors.Is(err, errAutoMergeWait): | |
| 258 | + | return nil | |
| 259 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated): | |
| 260 | + | return cancel(autoMergeReason(err)) | |
| 261 | + | default: | |
| 262 | + | return err | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Removing a known sentinel's presentation prefix is display formatting, not | |
| 267 | + | // error classification (which always uses errors.Is). | |
| 268 | + | func autoMergeReason(err error) string { | |
| 269 | + | text := err.Error() | |
| 270 | + | for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} { | |
| 271 | + | text = strings.TrimPrefix(text, sentinel.Error()+": ") | |
| 272 | + | } | |
| 273 | + | return text | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (s *AutoMerge) retry(key domain.RepoName) { | |
| 277 | + | s.retryMu.Lock() | |
| 278 | + | defer s.retryMu.Unlock() | |
| 279 | + | if s.closed || s.scheduled[key] { | |
| 280 | + | return | |
| 281 | + | } | |
| 282 | + | s.retries[key]++ | |
| 283 | + | n := s.retries[key] | |
| 284 | + | if n > mergeabilityRetries { | |
| 285 | + | delete(s.retries, key) | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | s.scheduled[key] = true | |
| 289 | + | it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key} | |
| 290 | + | i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int { | |
| 291 | + | if a.due.After(b.due) { | |
| 292 | + | return 1 | |
| 293 | + | } | |
| 294 | + | return -1 // equal dues keep their insertion order | |
| 295 | + | }) | |
| 296 | + | s.heap = slices.Insert(s.heap, i, it) | |
| 297 | + | if i == 0 { | |
| 298 | + | select { | |
| 299 | + | case s.retryKick <- struct{}{}: | |
| 300 | + | default: | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // scheduler is the only goroutine that waits for retries: it sleeps until the | |
| 306 | + | // earliest is due and puts it back in the normal queue. | |
| 307 | + | func (s *AutoMerge) scheduler() { | |
| 308 | + | defer s.wg.Done() | |
| 309 | + | // The timer armed for waited; a kick re-arms it only when the earliest | |
| 310 | + | // retry changed, so a stale kick never starts a second timer. | |
| 311 | + | var ( | |
| 312 | + | after <-chan time.Time | |
| 313 | + | waited retryItem | |
| 314 | + | ) | |
| 315 | + | for { | |
| 316 | + | s.retryMu.Lock() | |
| 317 | + | var head retryItem | |
| 318 | + | has := len(s.heap) > 0 | |
| 319 | + | if has { | |
| 320 | + | head = s.heap[0] | |
| 321 | + | } | |
| 322 | + | s.retryMu.Unlock() | |
| 323 | + | if !has { | |
| 324 | + | after = nil | |
| 325 | + | select { | |
| 326 | + | case <-s.ctx.Done(): | |
| 327 | + | return | |
| 328 | + | case <-s.retryKick: | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | if after == nil || head.key != waited.key || !head.due.Equal(waited.due) { | |
| 333 | + | after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head | |
| 334 | + | } | |
| 335 | + | select { | |
| 336 | + | case <-s.ctx.Done(): | |
| 337 | + | return | |
| 338 | + | case <-s.retryKick: | |
| 339 | + | continue // an earlier retry may have arrived | |
| 340 | + | case <-after: | |
| 341 | + | after = nil | |
| 342 | + | } | |
| 343 | + | for _, k := range s.popDue(waited) { | |
| 344 | + | if s.ctx.Err() != nil { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | s.markDirty(k) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // popDue removes the waited item and the ones due as well. The waited item is | |
| 353 | + | // removed regardless of the clock only when both its key and its deadline | |
| 354 | + | // match: a replacement retry of the same repository is kept until it is due. | |
| 355 | + | func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName { | |
| 356 | + | s.retryMu.Lock() | |
| 357 | + | defer s.retryMu.Unlock() | |
| 358 | + | now := s.cfg.Clock.Now() | |
| 359 | + | var out []domain.RepoName | |
| 360 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { | |
| 361 | + | if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) { | |
| 362 | + | return false | |
| 363 | + | } | |
| 364 | + | out = append(out, it.key) | |
| 365 | + | delete(s.scheduled, it.key) | |
| 366 | + | return true | |
| 367 | + | }) | |
| 368 | + | return out | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // retryDone forgets the failures of key after a success. | |
| 372 | + | func (s *AutoMerge) retryDone(key domain.RepoName) { | |
| 373 | + | s.retryMu.Lock() | |
| 374 | + | delete(s.retries, key) | |
| 375 | + | delete(s.scheduled, key) | |
| 376 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key }) | |
| 377 | + | select { | |
| 378 | + | case s.retryKick <- struct{}{}: | |
| 379 | + | default: | |
| 380 | + | } | |
| 381 | + | s.retryMu.Unlock() | |
| 382 | + | } | |
| @@ -0,0 +1,382 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "slices" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // AutoMergeConfig holds dependencies of the asynchronous merge evaluator. | |||
| 19 | + | type AutoMergeConfig struct { | |||
| 20 | + | MRs *MergeRequests | |||
| 21 | + | Store ports.MergeRequestStore | |||
| 22 | + | Meta ports.RepoMetaStore | |||
| 23 | + | Git ports.MergeGit | |||
| 24 | + | Checks ports.CIChecksReader | |||
| 25 | + | Activity ports.CIActivity | |||
| 26 | + | Tokens ports.TokenStore | |||
| 27 | + | Bus ports.EventBus | |||
| 28 | + | Clock ports.Clock | |||
| 29 | + | After func(time.Duration) <-chan time.Time | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // AutoMerge serializes evaluation within each repository. Triggers only enqueue | |||
| 33 | + | // work, including when invoked while CI or merge-request locks are held. | |||
| 34 | + | type AutoMerge struct { | |||
| 35 | + | cfg AutoMergeConfig | |||
| 36 | + | ctx context.Context | |||
| 37 | + | cancel context.CancelFunc | |||
| 38 | + | wg sync.WaitGroup | |||
| 39 | + | mu sync.Mutex | |||
| 40 | + | dirty map[domain.RepoName]bool | |||
| 41 | + | order []domain.RepoName | |||
| 42 | + | running, again map[domain.RepoName]bool | |||
| 43 | + | wake chan struct{} | |||
| 44 | + | retryMu sync.Mutex | |||
| 45 | + | retries map[domain.RepoName]int | |||
| 46 | + | scheduled map[domain.RepoName]bool | |||
| 47 | + | heap []retryItem | |||
| 48 | + | retryKick chan struct{} | |||
| 49 | + | closed bool | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | var _ ports.AutoMergeTrigger = (*AutoMerge)(nil) | |||
| 53 | + | ||||
| 54 | + | // NewAutoMerge builds an evaluator; call Start once, and Close before MRs.Wait. | |||
| 55 | + | func NewAutoMerge(ctx context.Context, cfg AutoMergeConfig) *AutoMerge { | |||
| 56 | + | if cfg.After == nil { | |||
| 57 | + | cfg.After = time.After | |||
| 58 | + | } | |||
| 59 | + | ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |||
| 60 | + | return &AutoMerge{cfg: cfg, ctx: ctx, cancel: cancel, | |||
| 61 | + | dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1), | |||
| 62 | + | retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Start starts workers and discovers repositories, retrying failed discovery. | |||
| 66 | + | func (s *AutoMerge) Start() { | |||
| 67 | + | s.wg.Add(2 + defaultMergeabilityWorkers) | |||
| 68 | + | go s.scheduler() | |||
| 69 | + | go func() { | |||
| 70 | + | defer s.wg.Done() | |||
| 71 | + | for attempt := 0; attempt <= mergeabilityRetries; attempt++ { | |||
| 72 | + | repos, err := s.cfg.Store.ListOpenAll(s.ctx) | |||
| 73 | + | if err != nil && s.ctx.Err() == nil { | |||
| 74 | + | ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("auto-merge: list the repositories at startup") | |||
| 75 | + | } | |||
| 76 | + | if err == nil { | |||
| 77 | + | for _, repo := range repos { | |||
| 78 | + | if repo.AutoMerge != nil { | |||
| 79 | + | s.markDirty(repo.Repo) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | if attempt == mergeabilityRetries { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | select { | |||
| 88 | + | case <-s.ctx.Done(): | |||
| 89 | + | return | |||
| 90 | + | case <-s.cfg.After(mergeBackoff(attempt + 1)): | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | }() | |||
| 94 | + | for range defaultMergeabilityWorkers { | |||
| 95 | + | go s.loop() | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Close cancels workers and pending retry waits, then waits for them. | |||
| 100 | + | func (s *AutoMerge) Close(_ context.Context) { | |||
| 101 | + | s.retryMu.Lock() | |||
| 102 | + | s.closed = true | |||
| 103 | + | s.retryMu.Unlock() | |||
| 104 | + | s.cancel() | |||
| 105 | + | s.wg.Wait() | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (s *AutoMerge) signal() { | |||
| 109 | + | select { | |||
| 110 | + | case s.wake <- struct{}{}: | |||
| 111 | + | default: | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (s *AutoMerge) markDirty(repo domain.RepoName) { | |||
| 116 | + | s.mu.Lock() | |||
| 117 | + | if s.running[repo] { | |||
| 118 | + | s.again[repo] = true | |||
| 119 | + | } else if !s.dirty[repo] { | |||
| 120 | + | s.dirty[repo] = true | |||
| 121 | + | s.order = append(s.order, repo) | |||
| 122 | + | } | |||
| 123 | + | s.mu.Unlock() | |||
| 124 | + | s.signal() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (s *AutoMerge) next() (domain.RepoName, bool) { | |||
| 128 | + | s.mu.Lock() | |||
| 129 | + | defer s.mu.Unlock() | |||
| 130 | + | if len(s.order) == 0 { | |||
| 131 | + | return domain.RepoName{}, false | |||
| 132 | + | } | |||
| 133 | + | repo := s.order[0] | |||
| 134 | + | s.order = s.order[1:] | |||
| 135 | + | delete(s.dirty, repo) | |||
| 136 | + | s.running[repo] = true | |||
| 137 | + | if len(s.order) > 0 { | |||
| 138 | + | s.signal() | |||
| 139 | + | } | |||
| 140 | + | return repo, true | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (s *AutoMerge) loop() { | |||
| 144 | + | defer s.wg.Done() | |||
| 145 | + | for s.ctx.Err() == nil { | |||
| 146 | + | repo, found := s.next() | |||
| 147 | + | if !found { | |||
| 148 | + | select { | |||
| 149 | + | case <-s.ctx.Done(): | |||
| 150 | + | return | |||
| 151 | + | case <-s.wake: | |||
| 152 | + | } | |||
| 153 | + | continue | |||
| 154 | + | } | |||
| 155 | + | if s.evaluate(repo) { | |||
| 156 | + | s.retryDone(repo) | |||
| 157 | + | } else { | |||
| 158 | + | s.retry(repo) | |||
| 159 | + | } | |||
| 160 | + | s.mu.Lock() | |||
| 161 | + | delete(s.running, repo) | |||
| 162 | + | if s.again[repo] { | |||
| 163 | + | delete(s.again, repo) | |||
| 164 | + | s.dirty[repo] = true | |||
| 165 | + | s.order = append(s.order, repo) | |||
| 166 | + | s.signal() | |||
| 167 | + | } | |||
| 168 | + | s.mu.Unlock() | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // OnRepoChanged marks and wakes only; it never merges on the caller's stack. | |||
| 173 | + | func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) } | |||
| 174 | + | ||||
| 175 | + | func (s *AutoMerge) evaluate(repo domain.RepoName) bool { | |||
| 176 | + | err := s.evaluateRepo(repo) | |||
| 177 | + | if err != nil && s.ctx.Err() == nil { | |||
| 178 | + | ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository") | |||
| 179 | + | } | |||
| 180 | + | return err == nil || s.ctx.Err() != nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error { | |||
| 184 | + | ctx := s.ctx | |||
| 185 | + | meta, err := s.cfg.Meta.Get(ctx, repo) | |||
| 186 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 187 | + | return nil | |||
| 188 | + | } | |||
| 189 | + | if err != nil { | |||
| 190 | + | return err | |||
| 191 | + | } | |||
| 192 | + | mrs, err := s.cfg.Store.ListOpen(ctx, repo) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | var failures []error | |||
| 197 | + | for _, mr := range mrs { | |||
| 198 | + | if ctx.Err() != nil { | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | if mr.AutoMerge == nil { | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | if err := s.evaluateMR(ctx, meta, mr); err != nil { | |||
| 205 | + | failures = append(failures, err) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | return errors.Join(failures...) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error { | |||
| 212 | + | intent := mr.AutoMerge | |||
| 213 | + | settings := repo.EffectiveSettings() | |||
| 214 | + | cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) } | |||
| 215 | + | if !settings.AllowAutoMerge { | |||
| 216 | + | return cancel("auto-merge was turned off in the repository settings") | |||
| 217 | + | } | |||
| 218 | + | if !settings.Allows(intent.Mode) { | |||
| 219 | + | return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode)) | |||
| 220 | + | } | |||
| 221 | + | head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source) | |||
| 222 | + | if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) { | |||
| 223 | + | return cancel("the source branch was deleted") | |||
| 224 | + | } | |||
| 225 | + | if err != nil { | |||
| 226 | + | return err | |||
| 227 | + | } | |||
| 228 | + | if head != intent.HeadID { | |||
| 229 | + | return cancel("the branch moved to " + head.String()[:7]) | |||
| 230 | + | } | |||
| 231 | + | p, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now()) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | if !valid || !p.Allows(domain.ScopeMRMerge, mr.Repo) || !p.AllowsRef("refs/heads/"+mr.Target) { | |||
| 236 | + | return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target) | |||
| 237 | + | } | |||
| 238 | + | if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) { | |||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String()) | |||
| 242 | + | if err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | passed := false | |||
| 246 | + | for _, check := range checks { | |||
| 247 | + | if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess { | |||
| 248 | + | passed = true | |||
| 249 | + | break | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | if !passed { | |||
| 253 | + | return nil | |||
| 254 | + | } | |||
| 255 | + | _, err = s.cfg.MRs.mergeAs(ctx, p, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: true, intent: intent}) | |||
| 256 | + | switch { | |||
| 257 | + | case err == nil, errors.Is(err, errAutoMergeWait): | |||
| 258 | + | return nil | |||
| 259 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated): | |||
| 260 | + | return cancel(autoMergeReason(err)) | |||
| 261 | + | default: | |||
| 262 | + | return err | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // Removing a known sentinel's presentation prefix is display formatting, not | |||
| 267 | + | // error classification (which always uses errors.Is). | |||
| 268 | + | func autoMergeReason(err error) string { | |||
| 269 | + | text := err.Error() | |||
| 270 | + | for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} { | |||
| 271 | + | text = strings.TrimPrefix(text, sentinel.Error()+": ") | |||
| 272 | + | } | |||
| 273 | + | return text | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (s *AutoMerge) retry(key domain.RepoName) { | |||
| 277 | + | s.retryMu.Lock() | |||
| 278 | + | defer s.retryMu.Unlock() | |||
| 279 | + | if s.closed || s.scheduled[key] { | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | s.retries[key]++ | |||
| 283 | + | n := s.retries[key] | |||
| 284 | + | if n > mergeabilityRetries { | |||
| 285 | + | delete(s.retries, key) | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | s.scheduled[key] = true | |||
| 289 | + | it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key} | |||
| 290 | + | i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int { | |||
| 291 | + | if a.due.After(b.due) { | |||
| 292 | + | return 1 | |||
| 293 | + | } | |||
| 294 | + | return -1 // equal dues keep their insertion order | |||
| 295 | + | }) | |||
| 296 | + | s.heap = slices.Insert(s.heap, i, it) | |||
| 297 | + | if i == 0 { | |||
| 298 | + | select { | |||
| 299 | + | case s.retryKick <- struct{}{}: | |||
| 300 | + | default: | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // scheduler is the only goroutine that waits for retries: it sleeps until the | |||
| 306 | + | // earliest is due and puts it back in the normal queue. | |||
| 307 | + | func (s *AutoMerge) scheduler() { | |||
| 308 | + | defer s.wg.Done() | |||
| 309 | + | // The timer armed for waited; a kick re-arms it only when the earliest | |||
| 310 | + | // retry changed, so a stale kick never starts a second timer. | |||
| 311 | + | var ( | |||
| 312 | + | after <-chan time.Time | |||
| 313 | + | waited retryItem | |||
| 314 | + | ) | |||
| 315 | + | for { | |||
| 316 | + | s.retryMu.Lock() | |||
| 317 | + | var head retryItem | |||
| 318 | + | has := len(s.heap) > 0 | |||
| 319 | + | if has { | |||
| 320 | + | head = s.heap[0] | |||
| 321 | + | } | |||
| 322 | + | s.retryMu.Unlock() | |||
| 323 | + | if !has { | |||
| 324 | + | after = nil | |||
| 325 | + | select { | |||
| 326 | + | case <-s.ctx.Done(): | |||
| 327 | + | return | |||
| 328 | + | case <-s.retryKick: | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | if after == nil || head.key != waited.key || !head.due.Equal(waited.due) { | |||
| 333 | + | after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head | |||
| 334 | + | } | |||
| 335 | + | select { | |||
| 336 | + | case <-s.ctx.Done(): | |||
| 337 | + | return | |||
| 338 | + | case <-s.retryKick: | |||
| 339 | + | continue // an earlier retry may have arrived | |||
| 340 | + | case <-after: | |||
| 341 | + | after = nil | |||
| 342 | + | } | |||
| 343 | + | for _, k := range s.popDue(waited) { | |||
| 344 | + | if s.ctx.Err() != nil { | |||
| 345 | + | return | |||
| 346 | + | } | |||
| 347 | + | s.markDirty(k) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // popDue removes the waited item and the ones due as well. The waited item is | |||
| 353 | + | // removed regardless of the clock only when both its key and its deadline | |||
| 354 | + | // match: a replacement retry of the same repository is kept until it is due. | |||
| 355 | + | func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName { | |||
| 356 | + | s.retryMu.Lock() | |||
| 357 | + | defer s.retryMu.Unlock() | |||
| 358 | + | now := s.cfg.Clock.Now() | |||
| 359 | + | var out []domain.RepoName | |||
| 360 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { | |||
| 361 | + | if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) { | |||
| 362 | + | return false | |||
| 363 | + | } | |||
| 364 | + | out = append(out, it.key) | |||
| 365 | + | delete(s.scheduled, it.key) | |||
| 366 | + | return true | |||
| 367 | + | }) | |||
| 368 | + | return out | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // retryDone forgets the failures of key after a success. | |||
| 372 | + | func (s *AutoMerge) retryDone(key domain.RepoName) { | |||
| 373 | + | s.retryMu.Lock() | |||
| 374 | + | delete(s.retries, key) | |||
| 375 | + | delete(s.scheduled, key) | |||
| 376 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key }) | |||
| 377 | + | select { | |||
| 378 | + | case s.retryKick <- struct{}{}: | |||
| 379 | + | default: | |||
| 380 | + | } | |||
| 381 | + | s.retryMu.Unlock() | |||
| 382 | + | } | |||
A internal/usecase/automerge_test.go +353 -0
| @@ -0,0 +1,353 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) { | |
| 22 | + | t.Helper() | |
| 23 | + | r := newMRRig(t) | |
| 24 | + | r.seed() | |
| 25 | + | r.feature("feat") | |
| 26 | + | mr := r.open("feat") | |
| 27 | + | checks := mocks.NewCIChecksReader(t) | |
| 28 | + | r.mrs.SetChecks(checks) | |
| 29 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)}) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | return r, mr, checks | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge { | |
| 35 | + | return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Bus: r.bus, Clock: fixedClock{}}) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest { | |
| 39 | + | t.Helper() | |
| 40 | + | enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{}) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | require.NotNil(t, enabled.AutoMerge) | |
| 43 | + | return enabled | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest { | |
| 47 | + | t.Helper() | |
| 48 | + | got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | return got | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) { | |
| 54 | + | for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} { | |
| 55 | + | t.Run(tc, func(t *testing.T) { | |
| 56 | + | r, mr, _ := autoRig(t) | |
| 57 | + | p := root | |
| 58 | + | want := domain.ErrPreconditionFailed | |
| 59 | + | switch tc { | |
| 60 | + | case "setting": | |
| 61 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)}) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | case "scope": | |
| 64 | + | p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String()) | |
| 65 | + | want = domain.ErrNotFound | |
| 66 | + | case "ref": | |
| 67 | + | p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}}) | |
| 68 | + | want = domain.ErrForbidden | |
| 69 | + | case "draft": | |
| 70 | + | mr.Title, _ = domain.ParseMRTitle("WIP: work") | |
| 71 | + | require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr)) | |
| 72 | + | case "no CI": | |
| 73 | + | r.mrs.SetChecks(nil) | |
| 74 | + | case "closed": | |
| 75 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | case "mode": | |
| 78 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | } | |
| 81 | + | _, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{}) | |
| 82 | + | require.ErrorIs(t, err, want) | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) { | |
| 88 | + | r, mr, _ := autoRig(t) | |
| 89 | + | trigger := mocks.NewAutoMergeTrigger(t) | |
| 90 | + | trigger.EXPECT().OnRepoChanged(mock.Anything, r.repo).Return().Twice() | |
| 91 | + | r.mrs.WithAutoMerge(trigger) | |
| 92 | + | r.drain() | |
| 93 | + | got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash}) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge) | |
| 96 | + | events := r.drain() | |
| 97 | + | require.Len(t, events, 1) | |
| 98 | + | assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0]) | |
| 99 | + | got = enableAuto(t, r, mr) | |
| 100 | + | assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy) | |
| 101 | + | assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestCancelAutoMergePublishesReason(t *testing.T) { | |
| 105 | + | r, mr, _ := autoRig(t) | |
| 106 | + | enableAuto(t, r, mr) | |
| 107 | + | r.drain() | |
| 108 | + | got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | assert.Nil(t, got.AutoMerge) | |
| 111 | + | events := r.drain() | |
| 112 | + | require.Len(t, events, 1) | |
| 113 | + | ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled) | |
| 114 | + | require.True(t, ok) | |
| 115 | + | assert.NotEmpty(t, ev.Reason) | |
| 116 | + | _, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number) | |
| 117 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) { | |
| 121 | + | r, mr, checks := autoRig(t) | |
| 122 | + | enableAuto(t, r, mr) | |
| 123 | + | activity := mocks.NewCIActivity(t) | |
| 124 | + | activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(false).Once() | |
| 125 | + | activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(true).Once() | |
| 126 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil) | |
| 127 | + | a := autoEvaluator(r, checks, activity, nil) | |
| 128 | + | defer a.Close(r.ctx) | |
| 129 | + | require.True(t, a.Evaluate(r.repo)) | |
| 130 | + | require.True(t, a.Evaluate(r.repo)) | |
| 131 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |
| 132 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) { | |
| 136 | + | r, mr, checks := autoRig(t) | |
| 137 | + | trigger := mocks.NewWorkflowTrigger(t) | |
| 138 | + | trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once() | |
| 139 | + | r.mrs.SetWorkflowTrigger(trigger) | |
| 140 | + | enableAuto(t, r, mr) | |
| 141 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |
| 142 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 143 | + | defer a.Close(r.ctx) | |
| 144 | + | r.drain() | |
| 145 | + | require.True(t, a.Evaluate(r.repo)) | |
| 146 | + | got := storedAuto(t, r, mr) | |
| 147 | + | assert.Equal(t, domain.MRStateMerged, got.State) | |
| 148 | + | assert.True(t, got.AutoMerged) | |
| 149 | + | assert.Nil(t, got.AutoMerge) | |
| 150 | + | events := r.drain() | |
| 151 | + | found := false | |
| 152 | + | for _, ev := range events { | |
| 153 | + | if merged, ok := ev.(domain.MergeRequestMerged); ok { | |
| 154 | + | assert.True(t, merged.Auto) | |
| 155 | + | found = true | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | require.True(t, found) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) { | |
| 162 | + | r, mr, checks := autoRig(t) | |
| 163 | + | enableAuto(t, r, mr) | |
| 164 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)}) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | // No successful check: an unsuccessful head waits rather than merging. | |
| 167 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, nil) | |
| 168 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 169 | + | defer a.Close(r.ctx) | |
| 170 | + | require.True(t, a.Evaluate(r.repo)) | |
| 171 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |
| 172 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") } | |
| 176 | + | func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") } | |
| 177 | + | func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") } | |
| 178 | + | func testAutoCancellation(t *testing.T, change string) { | |
| 179 | + | r, mr, checks := autoRig(t) | |
| 180 | + | enableAuto(t, r, mr) | |
| 181 | + | switch change { | |
| 182 | + | case "head": | |
| 183 | + | r.commit(author, "new", "new", "new head") | |
| 184 | + | r.push("feat") | |
| 185 | + | case "mode": | |
| 186 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})}) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | case "setting": | |
| 189 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)}) | |
| 190 | + | require.NoError(t, err) | |
| 191 | + | } | |
| 192 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 193 | + | defer a.Close(r.ctx) | |
| 194 | + | r.drain() | |
| 195 | + | require.True(t, a.Evaluate(r.repo)) | |
| 196 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |
| 197 | + | events := r.drain() | |
| 198 | + | require.Len(t, events, 1) | |
| 199 | + | assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0]) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) { | |
| 203 | + | for _, expired := range []bool{false, true} { | |
| 204 | + | t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) { | |
| 205 | + | r, mr, checks := autoRig(t) | |
| 206 | + | enableAuto(t, r, mr) | |
| 207 | + | enabled := storedAuto(t, r, mr) | |
| 208 | + | enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"} | |
| 209 | + | require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled)) | |
| 210 | + | tokens := mocks.NewTokenStore(t) | |
| 211 | + | if expired { | |
| 212 | + | tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil) | |
| 213 | + | } else { | |
| 214 | + | tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound) | |
| 215 | + | } | |
| 216 | + | a := autoEvaluator(r, checks, nil, tokens) | |
| 217 | + | defer a.Close(r.ctx) | |
| 218 | + | require.True(t, a.Evaluate(r.repo)) | |
| 219 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |
| 220 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestAutoMergeCancelsOnPermanentMergeFailure(t *testing.T) { | |
| 226 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 227 | + | t.Run(mode.String(), func(t *testing.T) { | |
| 228 | + | r, mr, checks := autoRig(t) | |
| 229 | + | _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode}) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | head := r.head() | |
| 232 | + | r.sh("checkout", "main") | |
| 233 | + | r.commit(author, "a.txt", "conflicting target\n", "move target") | |
| 234 | + | r.push("main") | |
| 235 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |
| 236 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 237 | + | defer a.Close(r.ctx) | |
| 238 | + | require.True(t, a.Evaluate(r.repo)) | |
| 239 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |
| 240 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |
| 241 | + | }) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) { | |
| 246 | + | for _, closeMR := range []bool{false, true} { | |
| 247 | + | t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) { | |
| 248 | + | r, mr, checks := autoRig(t) | |
| 249 | + | enabled := enableAuto(t, r, mr) | |
| 250 | + | if closeMR { | |
| 251 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | } else { | |
| 254 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil) | |
| 255 | + | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID}) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | } | |
| 258 | + | got := storedAuto(t, r, mr) | |
| 259 | + | assert.Nil(t, got.AutoMerge) | |
| 260 | + | assert.False(t, got.AutoMerged) | |
| 261 | + | }) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestAutoMergeStartupRescan(t *testing.T) { | |
| 266 | + | r, mr, checks := autoRig(t) | |
| 267 | + | enableAuto(t, r, mr) | |
| 268 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |
| 269 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 270 | + | a.Start() | |
| 271 | + | defer a.Close(r.ctx) | |
| 272 | + | require.Eventually(t, func() bool { | |
| 273 | + | got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number) | |
| 274 | + | return err == nil && got.AutoMerged | |
| 275 | + | }, 5*time.Second, time.Millisecond) | |
| 276 | + | a.Close(r.ctx) | |
| 277 | + | r.mrs.Wait(r.ctx) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func TestAutoMergeRetriesTransientErrors(t *testing.T) { | |
| 281 | + | ctx := context.Background() | |
| 282 | + | store := mocks.NewMergeRequestStore(t) | |
| 283 | + | meta := mocks.NewRepoMetaStore(t) | |
| 284 | + | clock := mocks.NewClock(t) | |
| 285 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 286 | + | repo := name(t, "proj") | |
| 287 | + | store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil) | |
| 288 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once() | |
| 289 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once() | |
| 290 | + | done := make(chan struct{}, 1) | |
| 291 | + | store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once() | |
| 292 | + | timer := make(chan time.Time, 1) | |
| 293 | + | armed := make(chan time.Duration, 1) | |
| 294 | + | a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }}) | |
| 295 | + | a.Start() | |
| 296 | + | defer a.Close(ctx) | |
| 297 | + | select { | |
| 298 | + | case d := <-armed: | |
| 299 | + | assert.Equal(t, time.Second, d) | |
| 300 | + | case <-time.After(5 * time.Second): | |
| 301 | + | t.Fatal("no retry scheduled") | |
| 302 | + | } | |
| 303 | + | timer <- now.Add(time.Second) | |
| 304 | + | select { | |
| 305 | + | case <-done: | |
| 306 | + | case <-time.After(5 * time.Second): | |
| 307 | + | t.Fatal("retry did not evaluate") | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) { | |
| 312 | + | r, mr, checks := autoRig(t) | |
| 313 | + | enableAuto(t, r, mr) | |
| 314 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |
| 315 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 316 | + | ci := newCIUnit(t, nil).ci | |
| 317 | + | ci.SetAutoMergeTrigger(a) | |
| 318 | + | gate := make(chan struct{}) | |
| 319 | + | entered := make(chan struct{}) | |
| 320 | + | r.mrs.SetAfterMergeGate(func() { close(entered); <-gate }) | |
| 321 | + | defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }() | |
| 322 | + | require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() { | |
| 323 | + | a.Start() | |
| 324 | + | done := make(chan struct{}) | |
| 325 | + | go func() { | |
| 326 | + | ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) }) | |
| 327 | + | close(done) | |
| 328 | + | }() | |
| 329 | + | select { | |
| 330 | + | case <-done: | |
| 331 | + | case <-time.After(5 * time.Second): | |
| 332 | + | t.Fatal("kick blocked behind merge lock") | |
| 333 | + | } | |
| 334 | + | })) | |
| 335 | + | select { | |
| 336 | + | case <-entered: | |
| 337 | + | case <-time.After(5 * time.Second): | |
| 338 | + | t.Fatal("merge never reached post-merge gate") | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) { | |
| 343 | + | for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} { | |
| 344 | + | r, mr, checks := autoRig(t) | |
| 345 | + | enableAuto(t, r, mr) | |
| 346 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil) | |
| 347 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 348 | + | require.True(t, a.Evaluate(r.repo)) | |
| 349 | + | a.Close(r.ctx) | |
| 350 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |
| 351 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |
| 352 | + | } | |
| 353 | + | } | |
| @@ -0,0 +1,353 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 18 | + | "bnema.dev/ermite/internal/usecase" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | r := newMRRig(t) | |||
| 24 | + | r.seed() | |||
| 25 | + | r.feature("feat") | |||
| 26 | + | mr := r.open("feat") | |||
| 27 | + | checks := mocks.NewCIChecksReader(t) | |||
| 28 | + | r.mrs.SetChecks(checks) | |||
| 29 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)}) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | return r, mr, checks | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge { | |||
| 35 | + | return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Bus: r.bus, Clock: fixedClock{}}) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest { | |||
| 39 | + | t.Helper() | |||
| 40 | + | enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{}) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | require.NotNil(t, enabled.AutoMerge) | |||
| 43 | + | return enabled | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest { | |||
| 47 | + | t.Helper() | |||
| 48 | + | got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | return got | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) { | |||
| 54 | + | for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} { | |||
| 55 | + | t.Run(tc, func(t *testing.T) { | |||
| 56 | + | r, mr, _ := autoRig(t) | |||
| 57 | + | p := root | |||
| 58 | + | want := domain.ErrPreconditionFailed | |||
| 59 | + | switch tc { | |||
| 60 | + | case "setting": | |||
| 61 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)}) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | case "scope": | |||
| 64 | + | p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String()) | |||
| 65 | + | want = domain.ErrNotFound | |||
| 66 | + | case "ref": | |||
| 67 | + | p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}}) | |||
| 68 | + | want = domain.ErrForbidden | |||
| 69 | + | case "draft": | |||
| 70 | + | mr.Title, _ = domain.ParseMRTitle("WIP: work") | |||
| 71 | + | require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr)) | |||
| 72 | + | case "no CI": | |||
| 73 | + | r.mrs.SetChecks(nil) | |||
| 74 | + | case "closed": | |||
| 75 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | case "mode": | |||
| 78 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})}) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | } | |||
| 81 | + | _, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{}) | |||
| 82 | + | require.ErrorIs(t, err, want) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) { | |||
| 88 | + | r, mr, _ := autoRig(t) | |||
| 89 | + | trigger := mocks.NewAutoMergeTrigger(t) | |||
| 90 | + | trigger.EXPECT().OnRepoChanged(mock.Anything, r.repo).Return().Twice() | |||
| 91 | + | r.mrs.WithAutoMerge(trigger) | |||
| 92 | + | r.drain() | |||
| 93 | + | got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash}) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge) | |||
| 96 | + | events := r.drain() | |||
| 97 | + | require.Len(t, events, 1) | |||
| 98 | + | assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0]) | |||
| 99 | + | got = enableAuto(t, r, mr) | |||
| 100 | + | assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy) | |||
| 101 | + | assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestCancelAutoMergePublishesReason(t *testing.T) { | |||
| 105 | + | r, mr, _ := autoRig(t) | |||
| 106 | + | enableAuto(t, r, mr) | |||
| 107 | + | r.drain() | |||
| 108 | + | got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | assert.Nil(t, got.AutoMerge) | |||
| 111 | + | events := r.drain() | |||
| 112 | + | require.Len(t, events, 1) | |||
| 113 | + | ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled) | |||
| 114 | + | require.True(t, ok) | |||
| 115 | + | assert.NotEmpty(t, ev.Reason) | |||
| 116 | + | _, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number) | |||
| 117 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) { | |||
| 121 | + | r, mr, checks := autoRig(t) | |||
| 122 | + | enableAuto(t, r, mr) | |||
| 123 | + | activity := mocks.NewCIActivity(t) | |||
| 124 | + | activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(false).Once() | |||
| 125 | + | activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(true).Once() | |||
| 126 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil) | |||
| 127 | + | a := autoEvaluator(r, checks, activity, nil) | |||
| 128 | + | defer a.Close(r.ctx) | |||
| 129 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 130 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 131 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |||
| 132 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) { | |||
| 136 | + | r, mr, checks := autoRig(t) | |||
| 137 | + | trigger := mocks.NewWorkflowTrigger(t) | |||
| 138 | + | trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once() | |||
| 139 | + | r.mrs.SetWorkflowTrigger(trigger) | |||
| 140 | + | enableAuto(t, r, mr) | |||
| 141 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |||
| 142 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 143 | + | defer a.Close(r.ctx) | |||
| 144 | + | r.drain() | |||
| 145 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 146 | + | got := storedAuto(t, r, mr) | |||
| 147 | + | assert.Equal(t, domain.MRStateMerged, got.State) | |||
| 148 | + | assert.True(t, got.AutoMerged) | |||
| 149 | + | assert.Nil(t, got.AutoMerge) | |||
| 150 | + | events := r.drain() | |||
| 151 | + | found := false | |||
| 152 | + | for _, ev := range events { | |||
| 153 | + | if merged, ok := ev.(domain.MergeRequestMerged); ok { | |||
| 154 | + | assert.True(t, merged.Auto) | |||
| 155 | + | found = true | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | require.True(t, found) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) { | |||
| 162 | + | r, mr, checks := autoRig(t) | |||
| 163 | + | enableAuto(t, r, mr) | |||
| 164 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)}) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | // No successful check: an unsuccessful head waits rather than merging. | |||
| 167 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, nil) | |||
| 168 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 169 | + | defer a.Close(r.ctx) | |||
| 170 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 171 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |||
| 172 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") } | |||
| 176 | + | func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") } | |||
| 177 | + | func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") } | |||
| 178 | + | func testAutoCancellation(t *testing.T, change string) { | |||
| 179 | + | r, mr, checks := autoRig(t) | |||
| 180 | + | enableAuto(t, r, mr) | |||
| 181 | + | switch change { | |||
| 182 | + | case "head": | |||
| 183 | + | r.commit(author, "new", "new", "new head") | |||
| 184 | + | r.push("feat") | |||
| 185 | + | case "mode": | |||
| 186 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})}) | |||
| 187 | + | require.NoError(t, err) | |||
| 188 | + | case "setting": | |||
| 189 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)}) | |||
| 190 | + | require.NoError(t, err) | |||
| 191 | + | } | |||
| 192 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 193 | + | defer a.Close(r.ctx) | |||
| 194 | + | r.drain() | |||
| 195 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 196 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 197 | + | events := r.drain() | |||
| 198 | + | require.Len(t, events, 1) | |||
| 199 | + | assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0]) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) { | |||
| 203 | + | for _, expired := range []bool{false, true} { | |||
| 204 | + | t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) { | |||
| 205 | + | r, mr, checks := autoRig(t) | |||
| 206 | + | enableAuto(t, r, mr) | |||
| 207 | + | enabled := storedAuto(t, r, mr) | |||
| 208 | + | enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"} | |||
| 209 | + | require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled)) | |||
| 210 | + | tokens := mocks.NewTokenStore(t) | |||
| 211 | + | if expired { | |||
| 212 | + | tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil) | |||
| 213 | + | } else { | |||
| 214 | + | tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound) | |||
| 215 | + | } | |||
| 216 | + | a := autoEvaluator(r, checks, nil, tokens) | |||
| 217 | + | defer a.Close(r.ctx) | |||
| 218 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 219 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 220 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |||
| 221 | + | }) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestAutoMergeCancelsOnPermanentMergeFailure(t *testing.T) { | |||
| 226 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |||
| 227 | + | t.Run(mode.String(), func(t *testing.T) { | |||
| 228 | + | r, mr, checks := autoRig(t) | |||
| 229 | + | _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode}) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | head := r.head() | |||
| 232 | + | r.sh("checkout", "main") | |||
| 233 | + | r.commit(author, "a.txt", "conflicting target\n", "move target") | |||
| 234 | + | r.push("main") | |||
| 235 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |||
| 236 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 237 | + | defer a.Close(r.ctx) | |||
| 238 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 239 | + | assert.Nil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 240 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) { | |||
| 246 | + | for _, closeMR := range []bool{false, true} { | |||
| 247 | + | t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) { | |||
| 248 | + | r, mr, checks := autoRig(t) | |||
| 249 | + | enabled := enableAuto(t, r, mr) | |||
| 250 | + | if closeMR { | |||
| 251 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |||
| 252 | + | require.NoError(t, err) | |||
| 253 | + | } else { | |||
| 254 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil) | |||
| 255 | + | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID}) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | } | |||
| 258 | + | got := storedAuto(t, r, mr) | |||
| 259 | + | assert.Nil(t, got.AutoMerge) | |||
| 260 | + | assert.False(t, got.AutoMerged) | |||
| 261 | + | }) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestAutoMergeStartupRescan(t *testing.T) { | |||
| 266 | + | r, mr, checks := autoRig(t) | |||
| 267 | + | enableAuto(t, r, mr) | |||
| 268 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |||
| 269 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 270 | + | a.Start() | |||
| 271 | + | defer a.Close(r.ctx) | |||
| 272 | + | require.Eventually(t, func() bool { | |||
| 273 | + | got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number) | |||
| 274 | + | return err == nil && got.AutoMerged | |||
| 275 | + | }, 5*time.Second, time.Millisecond) | |||
| 276 | + | a.Close(r.ctx) | |||
| 277 | + | r.mrs.Wait(r.ctx) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func TestAutoMergeRetriesTransientErrors(t *testing.T) { | |||
| 281 | + | ctx := context.Background() | |||
| 282 | + | store := mocks.NewMergeRequestStore(t) | |||
| 283 | + | meta := mocks.NewRepoMetaStore(t) | |||
| 284 | + | clock := mocks.NewClock(t) | |||
| 285 | + | clock.EXPECT().Now().Return(now).Maybe() | |||
| 286 | + | repo := name(t, "proj") | |||
| 287 | + | store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil) | |||
| 288 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once() | |||
| 289 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once() | |||
| 290 | + | done := make(chan struct{}, 1) | |||
| 291 | + | store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once() | |||
| 292 | + | timer := make(chan time.Time, 1) | |||
| 293 | + | armed := make(chan time.Duration, 1) | |||
| 294 | + | a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }}) | |||
| 295 | + | a.Start() | |||
| 296 | + | defer a.Close(ctx) | |||
| 297 | + | select { | |||
| 298 | + | case d := <-armed: | |||
| 299 | + | assert.Equal(t, time.Second, d) | |||
| 300 | + | case <-time.After(5 * time.Second): | |||
| 301 | + | t.Fatal("no retry scheduled") | |||
| 302 | + | } | |||
| 303 | + | timer <- now.Add(time.Second) | |||
| 304 | + | select { | |||
| 305 | + | case <-done: | |||
| 306 | + | case <-time.After(5 * time.Second): | |||
| 307 | + | t.Fatal("retry did not evaluate") | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) { | |||
| 312 | + | r, mr, checks := autoRig(t) | |||
| 313 | + | enableAuto(t, r, mr) | |||
| 314 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil) | |||
| 315 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 316 | + | ci := newCIUnit(t, nil).ci | |||
| 317 | + | ci.SetAutoMergeTrigger(a) | |||
| 318 | + | gate := make(chan struct{}) | |||
| 319 | + | entered := make(chan struct{}) | |||
| 320 | + | r.mrs.SetAfterMergeGate(func() { close(entered); <-gate }) | |||
| 321 | + | defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }() | |||
| 322 | + | require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() { | |||
| 323 | + | a.Start() | |||
| 324 | + | done := make(chan struct{}) | |||
| 325 | + | go func() { | |||
| 326 | + | ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) }) | |||
| 327 | + | close(done) | |||
| 328 | + | }() | |||
| 329 | + | select { | |||
| 330 | + | case <-done: | |||
| 331 | + | case <-time.After(5 * time.Second): | |||
| 332 | + | t.Fatal("kick blocked behind merge lock") | |||
| 333 | + | } | |||
| 334 | + | })) | |||
| 335 | + | select { | |||
| 336 | + | case <-entered: | |||
| 337 | + | case <-time.After(5 * time.Second): | |||
| 338 | + | t.Fatal("merge never reached post-merge gate") | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) { | |||
| 343 | + | for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} { | |||
| 344 | + | r, mr, checks := autoRig(t) | |||
| 345 | + | enableAuto(t, r, mr) | |||
| 346 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil) | |||
| 347 | + | a := autoEvaluator(r, checks, nil, nil) | |||
| 348 | + | require.True(t, a.Evaluate(r.repo)) | |||
| 349 | + | a.Close(r.ctx) | |||
| 350 | + | assert.True(t, storedAuto(t, r, mr).IsOpen()) | |||
| 351 | + | assert.NotNil(t, storedAuto(t, r, mr).AutoMerge) | |||
| 352 | + | } | |||
| 353 | + | } | |||
M internal/usecase/ci.go +52 -2
| @@ -53,8 +53,9 @@ const ( | |||
| 53 | 53 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | |
| 54 | 54 | // the defaults of Owner and Retention). | |
| 55 | 55 | type CIConfig struct { | |
| 56 | - | Store ports.CIStore | |
| 57 | - | Logs ports.CILogStore | |
| 56 | + | AutoMerge ports.AutoMergeTrigger | |
| 57 | + | Store ports.CIStore | |
| 58 | + | Logs ports.CILogStore | |
| 58 | 59 | // Summaries keeps the job summaries, deleted with the logs of their run. | |
| 59 | 60 | Summaries ports.CISummaryStore | |
| 60 | 61 | Meta ports.RepoMetaStore | |
| @@ -266,6 +267,9 @@ type CI struct { | |||
| 266 | 267 | pending notifier // woken when a job may be ready or a runner was removed | |
| 267 | 268 | flows *wfCache | |
| 268 | 269 | ||
| 270 | + | pushes map[domain.RepoName]int | |
| 271 | + | pulls map[pullKey]int | |
| 272 | + | autoMerge ports.AutoMergeTrigger | |
| 269 | 273 | mu sync.Mutex | |
| 270 | 274 | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 271 | 275 | seen map[string]time.Time // runner id -> last seen | |
| @@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 311 | 315 | cfg = cfg.withDefaults() | |
| 312 | 316 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 313 | 317 | return &CI{ | |
| 318 | + | pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge, | |
| 314 | 319 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | |
| 315 | 320 | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | |
| 316 | 321 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| @@ -560,6 +565,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 560 | 565 | } | |
| 561 | 566 | if completing { | |
| 562 | 567 | c.onRunEvent(ctx, rs.run, runCompleted) | |
| 568 | + | c.kickAutoMerge(ctx, rs.run.Repo) | |
| 563 | 569 | } | |
| 564 | 570 | return nil | |
| 565 | 571 | } | |
| @@ -740,3 +746,47 @@ func isHex40(s string) bool { | |||
| 740 | 746 | } | |
| 741 | 747 | return strings.Trim(s, "0123456789abcdef") == "" | |
| 742 | 748 | } | |
| 749 | + | ||
| 750 | + | // SetAutoMergeTrigger installs the evaluator after constructing the merge use cases. | |
| 751 | + | func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) { | |
| 752 | + | c.mu.Lock() | |
| 753 | + | c.autoMerge = t | |
| 754 | + | c.mu.Unlock() | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) { | |
| 758 | + | c.mu.Lock() | |
| 759 | + | t := c.autoMerge | |
| 760 | + | c.mu.Unlock() | |
| 761 | + | if t != nil { | |
| 762 | + | t.OnRepoChanged(ctx, repo) | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // Settled reports whether all push planning and pull activities have finished. | |
| 767 | + | // Workflow-run events, schedules and dispatches are intentionally not counted. | |
| 768 | + | // Source and head are reserved for finer-grained accounting; repo-wide pushes | |
| 769 | + | // conservatively delay requests even when an unrelated branch is updated. | |
| 770 | + | func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64, _ string, _ domain.ObjectID) bool { | |
| 771 | + | c.mu.Lock() | |
| 772 | + | defer c.mu.Unlock() | |
| 773 | + | return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0 | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | func (c *CI) finishPush(repo domain.RepoName) { | |
| 777 | + | c.mu.Lock() | |
| 778 | + | c.pushes[repo]-- | |
| 779 | + | if c.pushes[repo] == 0 { | |
| 780 | + | delete(c.pushes, repo) | |
| 781 | + | } | |
| 782 | + | c.mu.Unlock() | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | func (c *CI) finishPull(key pullKey) { | |
| 786 | + | c.mu.Lock() | |
| 787 | + | c.pulls[key]-- | |
| 788 | + | if c.pulls[key] == 0 { | |
| 789 | + | delete(c.pulls, key) | |
| 790 | + | } | |
| 791 | + | c.mu.Unlock() | |
| 792 | + | } | |
| @@ -53,8 +53,9 @@ const ( | |||||
| 53 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | 53 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | ||
| 54 | // the defaults of Owner and Retention). | 54 | // the defaults of Owner and Retention). | ||
| 55 | type CIConfig struct { | 55 | type CIConfig struct { | ||
| 56 | - | Store ports.CIStore | 56 | + | AutoMerge ports.AutoMergeTrigger |
| 57 | - | Logs ports.CILogStore | 57 | + | Store ports.CIStore |
| 58 | + | Logs ports.CILogStore | |||
| 58 | // Summaries keeps the job summaries, deleted with the logs of their run. | 59 | // Summaries keeps the job summaries, deleted with the logs of their run. | ||
| 59 | Summaries ports.CISummaryStore | 60 | Summaries ports.CISummaryStore | ||
| 60 | Meta ports.RepoMetaStore | 61 | Meta ports.RepoMetaStore | ||
| @@ -266,6 +267,9 @@ type CI struct { | |||||
| 266 | pending notifier // woken when a job may be ready or a runner was removed | 267 | pending notifier // woken when a job may be ready or a runner was removed | ||
| 267 | flows *wfCache | 268 | flows *wfCache | ||
| 268 | 269 | ||||
| 270 | + | pushes map[domain.RepoName]int | |||
| 271 | + | pulls map[pullKey]int | |||
| 272 | + | autoMerge ports.AutoMergeTrigger | |||
| 269 | mu sync.Mutex | 273 | mu sync.Mutex | ||
| 270 | heartbeats map[string]time.Time // job reference -> last heartbeat | 274 | heartbeats map[string]time.Time // job reference -> last heartbeat | ||
| 271 | seen map[string]time.Time // runner id -> last seen | 275 | seen map[string]time.Time // runner id -> last seen | ||
| @@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||||
| 311 | cfg = cfg.withDefaults() | 315 | cfg = cfg.withDefaults() | ||
| 312 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | 316 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 313 | return &CI{ | 317 | return &CI{ | ||
| 318 | + | pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge, | |||
| 314 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | 319 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | ||
| 315 | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | 320 | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | ||
| 316 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | 321 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | ||
| @@ -560,6 +565,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||||
| 560 | } | 565 | } | ||
| 561 | if completing { | 566 | if completing { | ||
| 562 | c.onRunEvent(ctx, rs.run, runCompleted) | 567 | c.onRunEvent(ctx, rs.run, runCompleted) | ||
| 568 | + | c.kickAutoMerge(ctx, rs.run.Repo) | |||
| 563 | } | 569 | } | ||
| 564 | return nil | 570 | return nil | ||
| 565 | } | 571 | } | ||
| @@ -740,3 +746,47 @@ func isHex40(s string) bool { | |||||
| 740 | } | 746 | } | ||
| 741 | return strings.Trim(s, "0123456789abcdef") == "" | 747 | return strings.Trim(s, "0123456789abcdef") == "" | ||
| 742 | } | 748 | } | ||
| 749 | + | ||||
| 750 | + | // SetAutoMergeTrigger installs the evaluator after constructing the merge use cases. | |||
| 751 | + | func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) { | |||
| 752 | + | c.mu.Lock() | |||
| 753 | + | c.autoMerge = t | |||
| 754 | + | c.mu.Unlock() | |||
| 755 | + | } | |||
| 756 | + | ||||
| 757 | + | func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) { | |||
| 758 | + | c.mu.Lock() | |||
| 759 | + | t := c.autoMerge | |||
| 760 | + | c.mu.Unlock() | |||
| 761 | + | if t != nil { | |||
| 762 | + | t.OnRepoChanged(ctx, repo) | |||
| 763 | + | } | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | // Settled reports whether all push planning and pull activities have finished. | |||
| 767 | + | // Workflow-run events, schedules and dispatches are intentionally not counted. | |||
| 768 | + | // Source and head are reserved for finer-grained accounting; repo-wide pushes | |||
| 769 | + | // conservatively delay requests even when an unrelated branch is updated. | |||
| 770 | + | func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64, _ string, _ domain.ObjectID) bool { | |||
| 771 | + | c.mu.Lock() | |||
| 772 | + | defer c.mu.Unlock() | |||
| 773 | + | return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0 | |||
| 774 | + | } | |||
| 775 | + | ||||
| 776 | + | func (c *CI) finishPush(repo domain.RepoName) { | |||
| 777 | + | c.mu.Lock() | |||
| 778 | + | c.pushes[repo]-- | |||
| 779 | + | if c.pushes[repo] == 0 { | |||
| 780 | + | delete(c.pushes, repo) | |||
| 781 | + | } | |||
| 782 | + | c.mu.Unlock() | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | func (c *CI) finishPull(key pullKey) { | |||
| 786 | + | c.mu.Lock() | |||
| 787 | + | c.pulls[key]-- | |||
| 788 | + | if c.pulls[key] == 0 { | |||
| 789 | + | delete(c.pulls, key) | |||
| 790 | + | } | |||
| 791 | + | c.mu.Unlock() | |||
| 792 | + | } | |||
M internal/usecase/ci_actions.go +22 -14
| @@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain. | |||
| 271 | 271 | if repo.Name == runRepo { | |
| 272 | 272 | return true, nil | |
| 273 | 273 | } | |
| 274 | - | var p domain.Principal | |
| 275 | - | switch by.pusher.Kind { | |
| 274 | + | p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now()) | |
| 275 | + | if err != nil { | |
| 276 | + | return false, err | |
| 277 | + | } | |
| 278 | + | return canRead(p, repo), nil | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // principalOfPusher re-resolves a recorded actor without substituting a more | |
| 282 | + | // privileged identity when its token no longer exists or has expired. | |
| 283 | + | func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) { | |
| 284 | + | switch by.Kind { | |
| 276 | 285 | case domain.CIPusherRoot: | |
| 277 | - | p = domain.Root() | |
| 286 | + | return domain.Root(), true, nil | |
| 278 | 287 | case domain.CIPusherOwner: | |
| 279 | - | p = domain.Owner() | |
| 288 | + | return domain.Owner(), true, nil | |
| 280 | 289 | case domain.CIPusherToken: | |
| 281 | - | if c.cfg.Tokens == nil { | |
| 282 | - | return false, nil | |
| 290 | + | if tokens == nil { | |
| 291 | + | return domain.Anonymous(), false, nil | |
| 283 | 292 | } | |
| 284 | - | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | |
| 293 | + | tok, err := tokens.Get(ctx, by.TokenID) | |
| 285 | 294 | if errors.Is(err, domain.ErrNotFound) { | |
| 286 | - | return false, nil | |
| 295 | + | return domain.Anonymous(), false, nil | |
| 287 | 296 | } | |
| 288 | 297 | if err != nil { | |
| 289 | - | return false, err | |
| 298 | + | return domain.Anonymous(), false, err | |
| 290 | 299 | } | |
| 291 | - | if tok.Expired(c.now()) { | |
| 292 | - | return false, nil | |
| 300 | + | if tok.Expired(now) { | |
| 301 | + | return domain.Anonymous(), false, nil | |
| 293 | 302 | } | |
| 294 | - | p = domain.TokenPrincipal(tok) | |
| 303 | + | return domain.TokenPrincipal(tok), true, nil | |
| 295 | 304 | default: | |
| 296 | - | p = domain.Anonymous() | |
| 305 | + | return domain.Anonymous(), false, nil | |
| 297 | 306 | } | |
| 298 | - | return canRead(p, repo), nil | |
| 299 | 307 | } | |
| 300 | 308 | ||
| 301 | 309 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | |
| @@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain. | |||||
| 271 | if repo.Name == runRepo { | 271 | if repo.Name == runRepo { | ||
| 272 | return true, nil | 272 | return true, nil | ||
| 273 | } | 273 | } | ||
| 274 | - | var p domain.Principal | 274 | + | p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now()) |
| 275 | - | switch by.pusher.Kind { | 275 | + | if err != nil { |
| 276 | + | return false, err | |||
| 277 | + | } | |||
| 278 | + | return canRead(p, repo), nil | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // principalOfPusher re-resolves a recorded actor without substituting a more | |||
| 282 | + | // privileged identity when its token no longer exists or has expired. | |||
| 283 | + | func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) { | |||
| 284 | + | switch by.Kind { | |||
| 276 | case domain.CIPusherRoot: | 285 | case domain.CIPusherRoot: | ||
| 277 | - | p = domain.Root() | 286 | + | return domain.Root(), true, nil |
| 278 | case domain.CIPusherOwner: | 287 | case domain.CIPusherOwner: | ||
| 279 | - | p = domain.Owner() | 288 | + | return domain.Owner(), true, nil |
| 280 | case domain.CIPusherToken: | 289 | case domain.CIPusherToken: | ||
| 281 | - | if c.cfg.Tokens == nil { | 290 | + | if tokens == nil { |
| 282 | - | return false, nil | 291 | + | return domain.Anonymous(), false, nil |
| 283 | } | 292 | } | ||
| 284 | - | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | 293 | + | tok, err := tokens.Get(ctx, by.TokenID) |
| 285 | if errors.Is(err, domain.ErrNotFound) { | 294 | if errors.Is(err, domain.ErrNotFound) { | ||
| 286 | - | return false, nil | 295 | + | return domain.Anonymous(), false, nil |
| 287 | } | 296 | } | ||
| 288 | if err != nil { | 297 | if err != nil { | ||
| 289 | - | return false, err | 298 | + | return domain.Anonymous(), false, err |
| 290 | } | 299 | } | ||
| 291 | - | if tok.Expired(c.now()) { | 300 | + | if tok.Expired(now) { |
| 292 | - | return false, nil | 301 | + | return domain.Anonymous(), false, nil |
| 293 | } | 302 | } | ||
| 294 | - | p = domain.TokenPrincipal(tok) | 303 | + | return domain.TokenPrincipal(tok), true, nil |
| 295 | default: | 304 | default: | ||
| 296 | - | p = domain.Anonymous() | 305 | + | return domain.Anonymous(), false, nil |
| 297 | } | 306 | } | ||
| 298 | - | return canRead(p, repo), nil | |||
| 299 | } | 307 | } | ||
| 300 | 308 | ||||
| 301 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | 309 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | ||
A internal/usecase/ci_activity_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | + | "bnema.dev/ermite/internal/usecase" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) { | |
| 18 | + | trigger := mocks.NewAutoMergeTrigger(t) | |
| 19 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger }) | |
| 20 | + | repo := name(t, "proj") | |
| 21 | + | trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Twice() | |
| 22 | + | u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once() | |
| 23 | + | ctx := context.Background() | |
| 24 | + | settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n, "feat", domain.ObjectID{}) } | |
| 25 | + | assert.True(t, settled(1)) | |
| 26 | + | u.ci.QueuePush(ctx, repo) | |
| 27 | + | u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one | |
| 28 | + | assert.False(t, settled(1)) | |
| 29 | + | assert.False(t, settled(2)) // pushes conservatively cover the whole repo | |
| 30 | + | u.ci.QueuePull(repo, 1) | |
| 31 | + | u.ci.QueuePull(repo, 1) // dropped: no counter leak | |
| 32 | + | u.ci.PlanQueuedPush() | |
| 33 | + | assert.False(t, settled(1)) | |
| 34 | + | assert.True(t, settled(2)) | |
| 35 | + | u.ci.FinishQueuedPull() | |
| 36 | + | assert.True(t, settled(1)) | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 14 | + | "bnema.dev/ermite/internal/usecase" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) { | |||
| 18 | + | trigger := mocks.NewAutoMergeTrigger(t) | |||
| 19 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger }) | |||
| 20 | + | repo := name(t, "proj") | |||
| 21 | + | trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Twice() | |||
| 22 | + | u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once() | |||
| 23 | + | ctx := context.Background() | |||
| 24 | + | settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n, "feat", domain.ObjectID{}) } | |||
| 25 | + | assert.True(t, settled(1)) | |||
| 26 | + | u.ci.QueuePush(ctx, repo) | |||
| 27 | + | u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one | |||
| 28 | + | assert.False(t, settled(1)) | |||
| 29 | + | assert.False(t, settled(2)) // pushes conservatively cover the whole repo | |||
| 30 | + | u.ci.QueuePull(repo, 1) | |||
| 31 | + | u.ci.QueuePull(repo, 1) // dropped: no counter leak | |||
| 32 | + | u.ci.PlanQueuedPush() | |||
| 33 | + | assert.False(t, settled(1)) | |||
| 34 | + | assert.True(t, settled(2)) | |||
| 35 | + | u.ci.FinishQueuedPull() | |||
| 36 | + | assert.True(t, settled(1)) | |||
| 37 | + | } | |||
M internal/usecase/ci_github_test.go +21 -0
| @@ -5,14 +5,17 @@ package usecase_test | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "math" | |
| 10 | 11 | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| 13 | 14 | ||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | 16 | "github.com/bnema/zerowrap" | |
| 15 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 16 | 19 | "github.com/stretchr/testify/require" | |
| 17 | 20 | ||
| 18 | 21 | "bnema.dev/ermite-actions/workflow" | |
| @@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) { | |||
| 109 | 112 | assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name) | |
| 110 | 113 | } | |
| 111 | 114 | } | |
| 115 | + | ||
| 116 | + | func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) { | |
| 117 | + | for _, allow := range []bool{false, true} { | |
| 118 | + | t.Run(strconv.FormatBool(allow), func(t *testing.T) { | |
| 119 | + | repos := mocks.NewGitRepos(t) | |
| 120 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos }) | |
| 121 | + | repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()} | |
| 122 | + | repo.Settings.AllowAutoMerge = allow | |
| 123 | + | u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil) | |
| 124 | + | repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil) | |
| 125 | + | data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj") | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | var obj map[string]any | |
| 128 | + | require.NoError(t, json.Unmarshal(data, &obj)) | |
| 129 | + | assert.Equal(t, allow, obj["allow_auto_merge"]) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -5,14 +5,17 @@ package usecase_test | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | + | "encoding/json" | |||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "math" | 10 | "math" | ||
| 10 | "strconv" | 11 | "strconv" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | 14 | ||||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 14 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 15 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 18 | + | "github.com/stretchr/testify/mock" | |||
| 16 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 17 | 20 | ||||
| 18 | "bnema.dev/ermite-actions/workflow" | 21 | "bnema.dev/ermite-actions/workflow" | ||
| @@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) { | |||||
| 109 | assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name) | 112 | assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name) | ||
| 110 | } | 113 | } | ||
| 111 | } | 114 | } | ||
| 115 | + | ||||
| 116 | + | func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) { | |||
| 117 | + | for _, allow := range []bool{false, true} { | |||
| 118 | + | t.Run(strconv.FormatBool(allow), func(t *testing.T) { | |||
| 119 | + | repos := mocks.NewGitRepos(t) | |||
| 120 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos }) | |||
| 121 | + | repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()} | |||
| 122 | + | repo.Settings.AllowAutoMerge = allow | |||
| 123 | + | u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil) | |||
| 124 | + | repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil) | |||
| 125 | + | data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj") | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | var obj map[string]any | |||
| 128 | + | require.NoError(t, json.Unmarshal(data, &obj)) | |||
| 129 | + | assert.Equal(t, allow, obj["allow_auto_merge"]) | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
M internal/usecase/ci_githubapi.go +1 -1
| @@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string | |||
| 74 | 74 | for k, v := range map[string]any{ | |
| 75 | 75 | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | |
| 76 | 76 | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | |
| 77 | - | "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, | |
| 77 | + | "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false, | |
| 78 | 78 | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | |
| 79 | 79 | } { | |
| 80 | 80 | obj[k] = v | |
| @@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string | |||||
| 74 | for k, v := range map[string]any{ | 74 | for k, v := range map[string]any{ | ||
| 75 | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | 75 | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | ||
| 76 | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | 76 | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | ||
| 77 | - | "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, | 77 | + | "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false, |
| 78 | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | 78 | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | ||
| 79 | } { | 79 | } { | ||
| 80 | obj[k] = v | 80 | obj[k] = v | ||
M internal/usecase/ci_pull.go +6 -0
| @@ -482,6 +482,9 @@ func (c *CI) wakePull() { | |||
| 482 | 482 | func (c *CI) queuePullEvent(e pullEvent) { | |
| 483 | 483 | c.mu.Lock() | |
| 484 | 484 | closed := c.closed | |
| 485 | + | if !closed { | |
| 486 | + | c.pulls[pullKey{repo: e.repo, number: e.number}]++ | |
| 487 | + | } | |
| 485 | 488 | c.mu.Unlock() | |
| 486 | 489 | if closed { | |
| 487 | 490 | return | |
| @@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) { | |||
| 489 | 492 | select { | |
| 490 | 493 | case c.pull.events <- e: | |
| 491 | 494 | default: | |
| 495 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |
| 492 | 496 | e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run") | |
| 493 | 497 | } | |
| 494 | 498 | } | |
| @@ -614,6 +618,8 @@ func (c *CI) pullLoop() { | |||
| 614 | 618 | return | |
| 615 | 619 | case e := <-c.pull.events: | |
| 616 | 620 | c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) }) | |
| 621 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |
| 622 | + | c.kickAutoMerge(c.ctx, e.repo) | |
| 617 | 623 | } | |
| 618 | 624 | } | |
| 619 | 625 | } | |
| @@ -482,6 +482,9 @@ func (c *CI) wakePull() { | |||||
| 482 | func (c *CI) queuePullEvent(e pullEvent) { | 482 | func (c *CI) queuePullEvent(e pullEvent) { | ||
| 483 | c.mu.Lock() | 483 | c.mu.Lock() | ||
| 484 | closed := c.closed | 484 | closed := c.closed | ||
| 485 | + | if !closed { | |||
| 486 | + | c.pulls[pullKey{repo: e.repo, number: e.number}]++ | |||
| 487 | + | } | |||
| 485 | c.mu.Unlock() | 488 | c.mu.Unlock() | ||
| 486 | if closed { | 489 | if closed { | ||
| 487 | return | 490 | return | ||
| @@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) { | |||||
| 489 | select { | 492 | select { | ||
| 490 | case c.pull.events <- e: | 493 | case c.pull.events <- e: | ||
| 491 | default: | 494 | default: | ||
| 495 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |||
| 492 | e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run") | 496 | e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run") | ||
| 493 | } | 497 | } | ||
| 494 | } | 498 | } | ||
| @@ -614,6 +618,8 @@ func (c *CI) pullLoop() { | |||||
| 614 | return | 618 | return | ||
| 615 | case e := <-c.pull.events: | 619 | case e := <-c.pull.events: | ||
| 616 | c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) }) | 620 | c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) }) | ||
| 621 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |||
| 622 | + | c.kickAutoMerge(c.ctx, e.repo) | |||
| 617 | } | 623 | } | ||
| 618 | } | 624 | } | ||
| 619 | } | 625 | } | ||
M internal/usecase/ci_trigger.go +5 -0
| @@ -101,6 +101,9 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain | |||
| 101 | 101 | t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 102 | 102 | c.mu.Lock() | |
| 103 | 103 | closed := c.closed | |
| 104 | + | if !closed { | |
| 105 | + | c.pushes[repo]++ | |
| 106 | + | } | |
| 104 | 107 | c.mu.Unlock() | |
| 105 | 108 | if closed { | |
| 106 | 109 | return | |
| @@ -108,6 +111,7 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain | |||
| 108 | 111 | select { | |
| 109 | 112 | case c.queue <- t: | |
| 110 | 113 | default: | |
| 114 | + | c.finishPush(repo) | |
| 111 | 115 | t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run") | |
| 112 | 116 | } | |
| 113 | 117 | } | |
| @@ -127,6 +131,7 @@ func (c *CI) triggerLoop() { | |||
| 127 | 131 | // planPush plans one push; a panic or an error is logged and never escapes. | |
| 128 | 132 | func (c *CI) planPush(t ciTrigger) { | |
| 129 | 133 | ctx := zerowrap.WithCtx(c.ctx, t.log) | |
| 134 | + | defer func() { c.finishPush(t.repo); c.kickAutoMerge(ctx, t.repo) }() | |
| 130 | 135 | defer func() { | |
| 131 | 136 | if r := recover(); r != nil { | |
| 132 | 137 | t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked") | |
| @@ -101,6 +101,9 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain | |||||
| 101 | t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)} | 101 | t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)} | ||
| 102 | c.mu.Lock() | 102 | c.mu.Lock() | ||
| 103 | closed := c.closed | 103 | closed := c.closed | ||
| 104 | + | if !closed { | |||
| 105 | + | c.pushes[repo]++ | |||
| 106 | + | } | |||
| 104 | c.mu.Unlock() | 107 | c.mu.Unlock() | ||
| 105 | if closed { | 108 | if closed { | ||
| 106 | return | 109 | return | ||
| @@ -108,6 +111,7 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain | |||||
| 108 | select { | 111 | select { | ||
| 109 | case c.queue <- t: | 112 | case c.queue <- t: | ||
| 110 | default: | 113 | default: | ||
| 114 | + | c.finishPush(repo) | |||
| 111 | t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run") | 115 | t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run") | ||
| 112 | } | 116 | } | ||
| 113 | } | 117 | } | ||
| @@ -127,6 +131,7 @@ func (c *CI) triggerLoop() { | |||||
| 127 | // planPush plans one push; a panic or an error is logged and never escapes. | 131 | // planPush plans one push; a panic or an error is logged and never escapes. | ||
| 128 | func (c *CI) planPush(t ciTrigger) { | 132 | func (c *CI) planPush(t ciTrigger) { | ||
| 129 | ctx := zerowrap.WithCtx(c.ctx, t.log) | 133 | ctx := zerowrap.WithCtx(c.ctx, t.log) | ||
| 134 | + | defer func() { c.finishPush(t.repo); c.kickAutoMerge(ctx, t.repo) }() | |||
| 130 | defer func() { | 135 | defer func() { | ||
| 131 | if r := recover(); r != nil { | 136 | if r := recover(); r != nil { | ||
| 132 | t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked") | 137 | t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked") | ||
M internal/usecase/events.go +9 -0
| @@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 182 | 182 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 183 | 183 | case domain.MergeRequestClosed: | |
| 184 | 184 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 185 | + | case domain.MergeRequestAutoMergeEnabled: | |
| 186 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 187 | + | case domain.MergeRequestAutoMergeCancelled: | |
| 188 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 185 | 189 | case domain.MergeRequestReady: | |
| 186 | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 187 | 191 | case domain.CommentAdded: | |
| @@ -533,6 +537,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 533 | 537 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 534 | 538 | case domain.MergeRequestClosed: | |
| 535 | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 540 | + | case domain.MergeRequestAutoMergeEnabled: | |
| 541 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 542 | + | case domain.MergeRequestAutoMergeCancelled: | |
| 543 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 536 | 544 | case domain.MergeRequestReady: | |
| 537 | 545 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 538 | 546 | case domain.CommentAdded: | |
| @@ -657,6 +665,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||
| 657 | 665 | func isItemEvent(ev domain.Event) bool { | |
| 658 | 666 | switch ev.(type) { | |
| 659 | 667 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 668 | + | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | |
| 660 | 669 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| 661 | 670 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | |
| 662 | 671 | return true | |
| @@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||||
| 182 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 182 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 183 | case domain.MergeRequestClosed: | 183 | case domain.MergeRequestClosed: | ||
| 184 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 184 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 185 | + | case domain.MergeRequestAutoMergeEnabled: | |||
| 186 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 187 | + | case domain.MergeRequestAutoMergeCancelled: | |||
| 188 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 185 | case domain.MergeRequestReady: | 189 | case domain.MergeRequestReady: | ||
| 186 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 187 | case domain.CommentAdded: | 191 | case domain.CommentAdded: | ||
| @@ -533,6 +537,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 533 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | 537 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | ||
| 534 | case domain.MergeRequestClosed: | 538 | case domain.MergeRequestClosed: | ||
| 535 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | ||
| 540 | + | case domain.MergeRequestAutoMergeEnabled: | |||
| 541 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |||
| 542 | + | case domain.MergeRequestAutoMergeCancelled: | |||
| 543 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |||
| 536 | case domain.MergeRequestReady: | 544 | case domain.MergeRequestReady: | ||
| 537 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | 545 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | ||
| 538 | case domain.CommentAdded: | 546 | case domain.CommentAdded: | ||
| @@ -657,6 +665,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||||
| 657 | func isItemEvent(ev domain.Event) bool { | 665 | func isItemEvent(ev domain.Event) bool { | ||
| 658 | switch ev.(type) { | 666 | switch ev.(type) { | ||
| 659 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | 667 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | ||
| 668 | + | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | |||
| 660 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | 669 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | ||
| 661 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | 670 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | ||
| 662 | return true | 671 | return true | ||
M internal/usecase/export_test.go +48 -0
| @@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma | |||
| 220 | 220 | ||
| 221 | 221 | // WindowLimits exposes windowLimits to the tests. | |
| 222 | 222 | var WindowLimits = windowLimits | |
| 223 | + | ||
| 224 | + | // SetChecks installs a checks reader before starting a test evaluator. | |
| 225 | + | func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks } | |
| 226 | + | ||
| 227 | + | // Evaluate exposes a single synchronous pass without starting workers. | |
| 228 | + | func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) } | |
| 229 | + | ||
| 230 | + | // QueuePush exposes the normal trigger queue for activity accounting tests. | |
| 231 | + | func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) { | |
| 232 | + | c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // PlanQueuedPush handles the next queued trigger without starting workers. | |
| 236 | + | func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) } | |
| 237 | + | ||
| 238 | + | // QueuePull exposes the pull activity queue for accounting tests. | |
| 239 | + | func (c *CI) QueuePull(repo domain.RepoName, number int64) { | |
| 240 | + | c.queuePullEvent(pullEvent{repo: repo, number: number}) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // FinishQueuedPull completes one test activity without planning workflows. | |
| 244 | + | func (c *CI) FinishQueuedPull() { | |
| 245 | + | e := <-c.pull.events | |
| 246 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |
| 247 | + | c.kickAutoMerge(c.ctx, e.repo) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // SetWorkflowTrigger installs the merge hook before evaluation. | |
| 251 | + | func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t } | |
| 252 | + | ||
| 253 | + | // UnderRunLock exposes the production run lock to test nonblocking triggers. | |
| 254 | + | func (c *CI) UnderRunLock(id int64, fn func()) { | |
| 255 | + | lock := c.runLock(id) | |
| 256 | + | lock.Lock() | |
| 257 | + | defer lock.Unlock() | |
| 258 | + | fn() | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // UnderRepoLock holds the production merge lock while a test interleaves a kick. | |
| 262 | + | func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error { | |
| 263 | + | unlock, err := m.lockRepo(ctx, repo) | |
| 264 | + | if err != nil { | |
| 265 | + | return err | |
| 266 | + | } | |
| 267 | + | defer unlock() | |
| 268 | + | fn() | |
| 269 | + | return nil | |
| 270 | + | } | |
| @@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma | |||||
| 220 | 220 | ||||
| 221 | // WindowLimits exposes windowLimits to the tests. | 221 | // WindowLimits exposes windowLimits to the tests. | ||
| 222 | var WindowLimits = windowLimits | 222 | var WindowLimits = windowLimits | ||
| 223 | + | ||||
| 224 | + | // SetChecks installs a checks reader before starting a test evaluator. | |||
| 225 | + | func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks } | |||
| 226 | + | ||||
| 227 | + | // Evaluate exposes a single synchronous pass without starting workers. | |||
| 228 | + | func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) } | |||
| 229 | + | ||||
| 230 | + | // QueuePush exposes the normal trigger queue for activity accounting tests. | |||
| 231 | + | func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) { | |||
| 232 | + | c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // PlanQueuedPush handles the next queued trigger without starting workers. | |||
| 236 | + | func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) } | |||
| 237 | + | ||||
| 238 | + | // QueuePull exposes the pull activity queue for accounting tests. | |||
| 239 | + | func (c *CI) QueuePull(repo domain.RepoName, number int64) { | |||
| 240 | + | c.queuePullEvent(pullEvent{repo: repo, number: number}) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // FinishQueuedPull completes one test activity without planning workflows. | |||
| 244 | + | func (c *CI) FinishQueuedPull() { | |||
| 245 | + | e := <-c.pull.events | |||
| 246 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |||
| 247 | + | c.kickAutoMerge(c.ctx, e.repo) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // SetWorkflowTrigger installs the merge hook before evaluation. | |||
| 251 | + | func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t } | |||
| 252 | + | ||||
| 253 | + | // UnderRunLock exposes the production run lock to test nonblocking triggers. | |||
| 254 | + | func (c *CI) UnderRunLock(id int64, fn func()) { | |||
| 255 | + | lock := c.runLock(id) | |||
| 256 | + | lock.Lock() | |||
| 257 | + | defer lock.Unlock() | |||
| 258 | + | fn() | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // UnderRepoLock holds the production merge lock while a test interleaves a kick. | |||
| 262 | + | func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error { | |||
| 263 | + | unlock, err := m.lockRepo(ctx, repo) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | defer unlock() | |||
| 268 | + | fn() | |||
| 269 | + | return nil | |||
| 270 | + | } | |||
M internal/usecase/mergeability.go +16 -6
| @@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration { | |||
| 35 | 35 | ||
| 36 | 36 | // MergeabilityConfig holds the dependencies of the repository reconciler. | |
| 37 | 37 | type MergeabilityConfig struct { | |
| 38 | - | Store ports.MergeRequestStore | |
| 39 | - | Meta ports.RepoMetaStore | |
| 40 | - | Git ports.MergeGit | |
| 41 | - | Bus ports.EventBus | |
| 42 | - | Clock ports.Clock | |
| 43 | - | Workers int // defaults to 2 | |
| 38 | + | AutoMerge ports.AutoMergeTrigger | |
| 39 | + | Store ports.MergeRequestStore | |
| 40 | + | Meta ports.RepoMetaStore | |
| 41 | + | Git ports.MergeGit | |
| 42 | + | Bus ports.EventBus | |
| 43 | + | Clock ports.Clock | |
| 44 | + | Workers int // defaults to 2 | |
| 44 | 45 | // After is injectable to drive backoff without wall-clock waits in tests. | |
| 45 | 46 | After func(time.Duration) <-chan time.Time | |
| 46 | 47 | } | |
| @@ -275,6 +276,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool { | |||
| 275 | 276 | Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt, | |
| 276 | 277 | Visibility: meta.Visibility, CreatedAt: meta.CreatedAt, | |
| 277 | 278 | }) | |
| 279 | + | if s.cfg.AutoMerge != nil { | |
| 280 | + | s.cfg.AutoMerge.OnRepoChanged(ctx, repo) | |
| 281 | + | } | |
| 278 | 282 | } | |
| 279 | 283 | return ok | |
| 280 | 284 | } | |
| @@ -465,3 +469,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number | |||
| 465 | 469 | m.Behind, m.BehindCapped = domain.BoundBehind(n) | |
| 466 | 470 | return m, nil | |
| 467 | 471 | } | |
| 472 | + | ||
| 473 | + | // WithAutoMerge installs the evaluator before Start. | |
| 474 | + | func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability { | |
| 475 | + | s.cfg.AutoMerge = t | |
| 476 | + | return s | |
| 477 | + | } | |
| @@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration { | |||||
| 35 | 35 | ||||
| 36 | // MergeabilityConfig holds the dependencies of the repository reconciler. | 36 | // MergeabilityConfig holds the dependencies of the repository reconciler. | ||
| 37 | type MergeabilityConfig struct { | 37 | type MergeabilityConfig struct { | ||
| 38 | - | Store ports.MergeRequestStore | 38 | + | AutoMerge ports.AutoMergeTrigger |
| 39 | - | Meta ports.RepoMetaStore | 39 | + | Store ports.MergeRequestStore |
| 40 | - | Git ports.MergeGit | 40 | + | Meta ports.RepoMetaStore |
| 41 | - | Bus ports.EventBus | 41 | + | Git ports.MergeGit |
| 42 | - | Clock ports.Clock | 42 | + | Bus ports.EventBus |
| 43 | - | Workers int // defaults to 2 | 43 | + | Clock ports.Clock |
| 44 | + | Workers int // defaults to 2 | |||
| 44 | // After is injectable to drive backoff without wall-clock waits in tests. | 45 | // After is injectable to drive backoff without wall-clock waits in tests. | ||
| 45 | After func(time.Duration) <-chan time.Time | 46 | After func(time.Duration) <-chan time.Time | ||
| 46 | } | 47 | } | ||
| @@ -275,6 +276,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool { | |||||
| 275 | Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt, | 276 | Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt, | ||
| 276 | Visibility: meta.Visibility, CreatedAt: meta.CreatedAt, | 277 | Visibility: meta.Visibility, CreatedAt: meta.CreatedAt, | ||
| 277 | }) | 278 | }) | ||
| 279 | + | if s.cfg.AutoMerge != nil { | |||
| 280 | + | s.cfg.AutoMerge.OnRepoChanged(ctx, repo) | |||
| 281 | + | } | |||
| 278 | } | 282 | } | ||
| 279 | return ok | 283 | return ok | ||
| 280 | } | 284 | } | ||
| @@ -465,3 +469,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number | |||||
| 465 | m.Behind, m.BehindCapped = domain.BoundBehind(n) | 469 | m.Behind, m.BehindCapped = domain.BoundBehind(n) | ||
| 466 | return m, nil | 470 | return m, nil | ||
| 467 | } | 471 | } | ||
| 472 | + | ||||
| 473 | + | // WithAutoMerge installs the evaluator before Start. | |||
| 474 | + | func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability { | |||
| 475 | + | s.cfg.AutoMerge = t | |||
| 476 | + | return s | |||
| 477 | + | } | |||
M internal/usecase/mergerequests.go +44 -18
| @@ -58,7 +58,8 @@ type MergeRequests struct { | |||
| 58 | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | |
| 59 | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |
| 60 | 60 | mergeab MergeabilityHook // nil: no mergeability | |
| 61 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 61 | + | autoMerge ports.AutoMergeTrigger | |
| 62 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 62 | 63 | ||
| 63 | 64 | mu sync.Mutex | |
| 64 | 65 | locks map[domain.RepoName]chan struct{} | |
| @@ -899,6 +900,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 899 | 900 | if err != nil { | |
| 900 | 901 | return domain.MergeRequest{}, err | |
| 901 | 902 | } | |
| 903 | + | hadAutoMerge := mr.AutoMerge != nil | |
| 904 | + | mr.AutoMerge = nil | |
| 902 | 905 | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | |
| 903 | 906 | err = m.store.Update(ctx, mr) | |
| 904 | 907 | if errors.Is(err, domain.ErrConflict) { | |
| @@ -912,6 +915,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 912 | 915 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 913 | 916 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 914 | 917 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 918 | + | if hadAutoMerge { | |
| 919 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 920 | + | } | |
| 915 | 921 | if m.pulls != nil { | |
| 916 | 922 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | |
| 917 | 923 | } | |
| @@ -989,7 +995,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||
| 989 | 995 | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | |
| 990 | 996 | // only says that the deletion was requested. | |
| 991 | 997 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |
| 992 | - | mr, err := m.merge(ctx, p, name, number, params) | |
| 998 | + | mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{}) | |
| 993 | 999 | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | |
| 994 | 1000 | } | |
| 995 | 1001 | ||
| @@ -1031,7 +1037,14 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m | |||
| 1031 | 1037 | return res.Tree, nil | |
| 1032 | 1038 | } | |
| 1033 | 1039 | ||
| 1034 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |
| 1040 | + | type mergeOpts struct { | |
| 1041 | + | auto bool | |
| 1042 | + | intent *domain.AutoMerge | |
| 1043 | + | } | |
| 1044 | + | ||
| 1045 | + | var errAutoMergeWait = errors.New("auto-merge is waiting for checks") | |
| 1046 | + | ||
| 1047 | + | func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) { | |
| 1035 | 1048 | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 1036 | 1049 | return domain.MergeRequest{}, err | |
| 1037 | 1050 | } | |
| @@ -1062,6 +1075,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1062 | 1075 | if err != nil { | |
| 1063 | 1076 | return domain.MergeRequest{}, err | |
| 1064 | 1077 | } | |
| 1078 | + | if opts.auto && (mr.AutoMerge == nil || opts.intent == nil || *mr.AutoMerge != *opts.intent) { | |
| 1079 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1080 | + | } | |
| 1065 | 1081 | // A merge moves the target and may delete the source: a token limited to | |
| 1066 | 1082 | // branch prefixes may do so only within them. | |
| 1067 | 1083 | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| @@ -1117,11 +1133,30 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1117 | 1133 | if err != nil { | |
| 1118 | 1134 | return domain.MergeRequest{}, err | |
| 1119 | 1135 | } | |
| 1120 | - | if settings.FailingChecksBlock { | |
| 1136 | + | if opts.auto && !settings.AllowAutoMerge { | |
| 1137 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name) | |
| 1138 | + | } | |
| 1139 | + | if settings.FailingChecksBlock || opts.auto { | |
| 1121 | 1140 | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 1122 | 1141 | return domain.MergeRequest{}, err | |
| 1123 | 1142 | } | |
| 1124 | 1143 | } | |
| 1144 | + | if opts.auto { | |
| 1145 | + | checks, err := m.checks.CommitChecks(ctx, name, head.String()) | |
| 1146 | + | if err != nil { | |
| 1147 | + | return domain.MergeRequest{}, err | |
| 1148 | + | } | |
| 1149 | + | passed := false | |
| 1150 | + | for _, check := range checks { | |
| 1151 | + | if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess { | |
| 1152 | + | passed = true | |
| 1153 | + | break | |
| 1154 | + | } | |
| 1155 | + | } | |
| 1156 | + | if !passed { | |
| 1157 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1158 | + | } | |
| 1159 | + | } | |
| 1125 | 1160 | if params.DeleteSource { | |
| 1126 | 1161 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 1127 | 1162 | return domain.MergeRequest{}, err | |
| @@ -1133,7 +1168,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1133 | 1168 | return domain.MergeRequest{}, err | |
| 1134 | 1169 | } | |
| 1135 | 1170 | m.recordPush(ctx, name) // the target moved, whatever happens next | |
| 1136 | - | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |
| 1171 | + | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto) | |
| 1137 | 1172 | if err != nil { | |
| 1138 | 1173 | return domain.MergeRequest{}, err | |
| 1139 | 1174 | } | |
| @@ -1175,7 +1210,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||
| 1175 | 1210 | } | |
| 1176 | 1211 | at := mr.UpdatedAt | |
| 1177 | 1212 | recordActivity(ctx, m.meta, repo.Name, at) | |
| 1178 | - | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1213 | + | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1179 | 1214 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1180 | 1215 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 1181 | 1216 | }) | |
| @@ -1273,7 +1308,7 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h | |||
| 1273 | 1308 | case len(failing) > 0: | |
| 1274 | 1309 | return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", ")) | |
| 1275 | 1310 | case len(pending) > 0: | |
| 1276 | - | return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", ")) | |
| 1311 | + | return fmt.Errorf("%w: %w: checks have not finished: %s", domain.ErrPreconditionFailed, errAutoMergeWait, strings.Join(pending, ", ")) | |
| 1277 | 1312 | } | |
| 1278 | 1313 | return nil | |
| 1279 | 1314 | } | |
| @@ -1365,7 +1400,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||
| 1365 | 1400 | ||
| 1366 | 1401 | // markMerged records the merge. The target already moved, so a write that | |
| 1367 | 1402 | // loses a race (a comment added meanwhile) is repeated on the fresh request. | |
| 1368 | - | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) { | |
| 1403 | + | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) { | |
| 1369 | 1404 | // The merge must be recorded even when the caller gives up. | |
| 1370 | 1405 | ctx = context.WithoutCancel(ctx) | |
| 1371 | 1406 | for range maxWriteRetries { | |
| @@ -1373,6 +1408,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu | |||
| 1373 | 1408 | if err != nil { | |
| 1374 | 1409 | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |
| 1375 | 1410 | } | |
| 1411 | + | mr.AutoMerge, mr.AutoMerged = nil, auto | |
| 1376 | 1412 | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | |
| 1377 | 1413 | mr.UpdatedAt = m.clock.Now().UTC() | |
| 1378 | 1414 | err = m.store.Update(ctx, mr) | |
| @@ -1520,13 +1556,3 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep | |||
| 1520 | 1556 | } | |
| 1521 | 1557 | return ports.PushSummary{Count: len(commits), More: more}, nil | |
| 1522 | 1558 | } | |
| 1523 | - | ||
| 1524 | - | // EnableAutoMerge is not implemented yet. | |
| 1525 | - | func (m *MergeRequests) EnableAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64, _ ports.EnableAutoMergeParams) (domain.MergeRequest, error) { | |
| 1526 | - | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed) | |
| 1527 | - | } | |
| 1528 | - | ||
| 1529 | - | // CancelAutoMerge is not implemented yet. | |
| 1530 | - | func (m *MergeRequests) CancelAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64) (domain.MergeRequest, error) { | |
| 1531 | - | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed) | |
| 1532 | - | } | |
| @@ -58,7 +58,8 @@ type MergeRequests struct { | |||||
| 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | ||
| 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | ||
| 60 | mergeab MergeabilityHook // nil: no mergeability | 60 | mergeab MergeabilityHook // nil: no mergeability | ||
| 61 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | 61 | + | autoMerge ports.AutoMergeTrigger |
| 62 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 62 | 63 | ||||
| 63 | mu sync.Mutex | 64 | mu sync.Mutex | ||
| 64 | locks map[domain.RepoName]chan struct{} | 65 | locks map[domain.RepoName]chan struct{} | ||
| @@ -899,6 +900,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||||
| 899 | if err != nil { | 900 | if err != nil { | ||
| 900 | return domain.MergeRequest{}, err | 901 | return domain.MergeRequest{}, err | ||
| 901 | } | 902 | } | ||
| 903 | + | hadAutoMerge := mr.AutoMerge != nil | |||
| 904 | + | mr.AutoMerge = nil | |||
| 902 | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | 905 | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | ||
| 903 | err = m.store.Update(ctx, mr) | 906 | err = m.store.Update(ctx, mr) | ||
| 904 | if errors.Is(err, domain.ErrConflict) { | 907 | if errors.Is(err, domain.ErrConflict) { | ||
| @@ -912,6 +915,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") | 915 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | ||
| 913 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | 916 | 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}) | 917 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 918 | + | if hadAutoMerge { | |||
| 919 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 920 | + | } | |||
| 915 | if m.pulls != nil { | 921 | if m.pulls != nil { | ||
| 916 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | 922 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | ||
| 917 | } | 923 | } | ||
| @@ -989,7 +995,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||||
| 989 | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | 995 | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | ||
| 990 | // only says that the deletion was requested. | 996 | // only says that the deletion was requested. | ||
| 991 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | 997 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | ||
| 992 | - | mr, err := m.merge(ctx, p, name, number, params) | 998 | + | mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{}) |
| 993 | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | 999 | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | ||
| 994 | } | 1000 | } | ||
| 995 | 1001 | ||||
| @@ -1031,7 +1037,14 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m | |||||
| 1031 | return res.Tree, nil | 1037 | return res.Tree, nil | ||
| 1032 | } | 1038 | } | ||
| 1033 | 1039 | ||||
| 1034 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | 1040 | + | type mergeOpts struct { |
| 1041 | + | auto bool | |||
| 1042 | + | intent *domain.AutoMerge | |||
| 1043 | + | } | |||
| 1044 | + | ||||
| 1045 | + | var errAutoMergeWait = errors.New("auto-merge is waiting for checks") | |||
| 1046 | + | ||||
| 1047 | + | func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) { | |||
| 1035 | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | 1048 | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | ||
| 1036 | return domain.MergeRequest{}, err | 1049 | return domain.MergeRequest{}, err | ||
| 1037 | } | 1050 | } | ||
| @@ -1062,6 +1075,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 1062 | if err != nil { | 1075 | if err != nil { | ||
| 1063 | return domain.MergeRequest{}, err | 1076 | return domain.MergeRequest{}, err | ||
| 1064 | } | 1077 | } | ||
| 1078 | + | if opts.auto && (mr.AutoMerge == nil || opts.intent == nil || *mr.AutoMerge != *opts.intent) { | |||
| 1079 | + | return domain.MergeRequest{}, errAutoMergeWait | |||
| 1080 | + | } | |||
| 1065 | // A merge moves the target and may delete the source: a token limited to | 1081 | // A merge moves the target and may delete the source: a token limited to | ||
| 1066 | // branch prefixes may do so only within them. | 1082 | // branch prefixes may do so only within them. | ||
| 1067 | if !p.AllowsRef("refs/heads/" + mr.Target) { | 1083 | if !p.AllowsRef("refs/heads/" + mr.Target) { | ||
| @@ -1117,11 +1133,30 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 1117 | if err != nil { | 1133 | if err != nil { | ||
| 1118 | return domain.MergeRequest{}, err | 1134 | return domain.MergeRequest{}, err | ||
| 1119 | } | 1135 | } | ||
| 1120 | - | if settings.FailingChecksBlock { | 1136 | + | if opts.auto && !settings.AllowAutoMerge { |
| 1137 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name) | |||
| 1138 | + | } | |||
| 1139 | + | if settings.FailingChecksBlock || opts.auto { | |||
| 1121 | if err := m.checksAllow(ctx, name, head); err != nil { | 1140 | if err := m.checksAllow(ctx, name, head); err != nil { | ||
| 1122 | return domain.MergeRequest{}, err | 1141 | return domain.MergeRequest{}, err | ||
| 1123 | } | 1142 | } | ||
| 1124 | } | 1143 | } | ||
| 1144 | + | if opts.auto { | |||
| 1145 | + | checks, err := m.checks.CommitChecks(ctx, name, head.String()) | |||
| 1146 | + | if err != nil { | |||
| 1147 | + | return domain.MergeRequest{}, err | |||
| 1148 | + | } | |||
| 1149 | + | passed := false | |||
| 1150 | + | for _, check := range checks { | |||
| 1151 | + | if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess { | |||
| 1152 | + | passed = true | |||
| 1153 | + | break | |||
| 1154 | + | } | |||
| 1155 | + | } | |||
| 1156 | + | if !passed { | |||
| 1157 | + | return domain.MergeRequest{}, errAutoMergeWait | |||
| 1158 | + | } | |||
| 1159 | + | } | |||
| 1125 | if params.DeleteSource { | 1160 | if params.DeleteSource { | ||
| 1126 | if err := m.checkDeletable(ctx, name, mr); err != nil { | 1161 | if err := m.checkDeletable(ctx, name, mr); err != nil { | ||
| 1127 | return domain.MergeRequest{}, err | 1162 | return domain.MergeRequest{}, err | ||
| @@ -1133,7 +1168,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 1133 | return domain.MergeRequest{}, err | 1168 | return domain.MergeRequest{}, err | ||
| 1134 | } | 1169 | } | ||
| 1135 | m.recordPush(ctx, name) // the target moved, whatever happens next | 1170 | m.recordPush(ctx, name) // the target moved, whatever happens next | ||
| 1136 | - | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | 1171 | + | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto) |
| 1137 | if err != nil { | 1172 | if err != nil { | ||
| 1138 | return domain.MergeRequest{}, err | 1173 | return domain.MergeRequest{}, err | ||
| 1139 | } | 1174 | } | ||
| @@ -1175,7 +1210,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||||
| 1175 | } | 1210 | } | ||
| 1176 | at := mr.UpdatedAt | 1211 | at := mr.UpdatedAt | ||
| 1177 | recordActivity(ctx, m.meta, repo.Name, at) | 1212 | recordActivity(ctx, m.meta, repo.Name, at) | ||
| 1178 | - | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 1213 | + | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) |
| 1179 | m.bus.Publish(ctx, domain.PushEvent{ | 1214 | m.bus.Publish(ctx, domain.PushEvent{ | ||
| 1180 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | 1215 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | ||
| 1181 | }) | 1216 | }) | ||
| @@ -1273,7 +1308,7 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h | |||||
| 1273 | case len(failing) > 0: | 1308 | case len(failing) > 0: | ||
| 1274 | return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", ")) | 1309 | return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", ")) | ||
| 1275 | case len(pending) > 0: | 1310 | case len(pending) > 0: | ||
| 1276 | - | return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", ")) | 1311 | + | return fmt.Errorf("%w: %w: checks have not finished: %s", domain.ErrPreconditionFailed, errAutoMergeWait, strings.Join(pending, ", ")) |
| 1277 | } | 1312 | } | ||
| 1278 | return nil | 1313 | return nil | ||
| 1279 | } | 1314 | } | ||
| @@ -1365,7 +1400,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||||
| 1365 | 1400 | ||||
| 1366 | // markMerged records the merge. The target already moved, so a write that | 1401 | // markMerged records the merge. The target already moved, so a write that | ||
| 1367 | // loses a race (a comment added meanwhile) is repeated on the fresh request. | 1402 | // loses a race (a comment added meanwhile) is repeated on the fresh request. | ||
| 1368 | - | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) { | 1403 | + | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) { |
| 1369 | // The merge must be recorded even when the caller gives up. | 1404 | // The merge must be recorded even when the caller gives up. | ||
| 1370 | ctx = context.WithoutCancel(ctx) | 1405 | ctx = context.WithoutCancel(ctx) | ||
| 1371 | for range maxWriteRetries { | 1406 | for range maxWriteRetries { | ||
| @@ -1373,6 +1408,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu | |||||
| 1373 | if err != nil { | 1408 | if err != nil { | ||
| 1374 | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | 1409 | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | ||
| 1375 | } | 1410 | } | ||
| 1411 | + | mr.AutoMerge, mr.AutoMerged = nil, auto | |||
| 1376 | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | 1412 | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | ||
| 1377 | mr.UpdatedAt = m.clock.Now().UTC() | 1413 | mr.UpdatedAt = m.clock.Now().UTC() | ||
| 1378 | err = m.store.Update(ctx, mr) | 1414 | err = m.store.Update(ctx, mr) | ||
| @@ -1520,13 +1556,3 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep | |||||
| 1520 | } | 1556 | } | ||
| 1521 | return ports.PushSummary{Count: len(commits), More: more}, nil | 1557 | return ports.PushSummary{Count: len(commits), More: more}, nil | ||
| 1522 | } | 1558 | } | ||
| 1523 | - | ||||
| 1524 | - | // EnableAutoMerge is not implemented yet. | |||
| 1525 | - | func (m *MergeRequests) EnableAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64, _ ports.EnableAutoMergeParams) (domain.MergeRequest, error) { | |||
| 1526 | - | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed) | |||
| 1527 | - | } | |||
| 1528 | - | ||||
| 1529 | - | // CancelAutoMerge is not implemented yet. | |||
| 1530 | - | func (m *MergeRequests) CancelAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64) (domain.MergeRequest, error) { | |||
| 1531 | - | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed) | |||
| 1532 | - | } | |||
A internal/usecase/mergerequests_auto.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // WithAutoMerge installs the asynchronous evaluator before serving requests. | |
| 15 | + | func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests { | |
| 16 | + | m.autoMerge = t | |
| 17 | + | return m | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // EnableAutoMerge records the actor and current head; re-enabling replaces the | |
| 21 | + | // previous intent rather than preserving an older actor's authority. | |
| 22 | + | func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) { | |
| 23 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 24 | + | return domain.MergeRequest{}, err | |
| 25 | + | } | |
| 26 | + | if m.checks == nil { | |
| 27 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed) | |
| 28 | + | } | |
| 29 | + | mode := params.Mode | |
| 30 | + | if mode == "" { | |
| 31 | + | mode = domain.MergeModeFastForward | |
| 32 | + | } | |
| 33 | + | if _, err := domain.ParseMergeMode(mode.String()); err != nil { | |
| 34 | + | return domain.MergeRequest{}, err | |
| 35 | + | } | |
| 36 | + | unlock, err := m.lockRepo(ctx, name) | |
| 37 | + | if err != nil { | |
| 38 | + | return domain.MergeRequest{}, err | |
| 39 | + | } | |
| 40 | + | defer unlock() | |
| 41 | + | for range maxWriteRetries { | |
| 42 | + | mr, err := m.store.Get(ctx, name, number) | |
| 43 | + | if err != nil { | |
| 44 | + | return domain.MergeRequest{}, err | |
| 45 | + | } | |
| 46 | + | if !mr.IsOpen() { | |
| 47 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 48 | + | } | |
| 49 | + | if mr.IsDraft() { | |
| 50 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 51 | + | } | |
| 52 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 53 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 54 | + | } | |
| 55 | + | repo, err := m.meta.Get(ctx, name) | |
| 56 | + | if err != nil { | |
| 57 | + | return domain.MergeRequest{}, err | |
| 58 | + | } | |
| 59 | + | settings := repo.EffectiveSettings() | |
| 60 | + | if !settings.AllowAutoMerge { | |
| 61 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name) | |
| 62 | + | } | |
| 63 | + | if !settings.Allows(mode) { | |
| 64 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 65 | + | } | |
| 66 | + | head, err := m.branchOrZero(ctx, name, mr.Source) | |
| 67 | + | if err != nil { | |
| 68 | + | return domain.MergeRequest{}, err | |
| 69 | + | } | |
| 70 | + | if head.IsZero() { | |
| 71 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 72 | + | } | |
| 73 | + | mr.UpdatedAt = m.clock.Now().UTC() | |
| 74 | + | mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt} | |
| 75 | + | if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) { | |
| 76 | + | continue | |
| 77 | + | } else if err != nil { | |
| 78 | + | return domain.MergeRequest{}, err | |
| 79 | + | } | |
| 80 | + | recordActivity(ctx, m.meta, name, mr.UpdatedAt) | |
| 81 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 82 | + | if m.autoMerge != nil { | |
| 83 | + | m.autoMerge.OnRepoChanged(ctx, name) | |
| 84 | + | } | |
| 85 | + | return m.withHead(ctx, mr) | |
| 86 | + | } | |
| 87 | + | return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // CancelAutoMerge clears an open request's intent with the same target access | |
| 91 | + | // required to enable it. | |
| 92 | + | func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 93 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 94 | + | return domain.MergeRequest{}, err | |
| 95 | + | } | |
| 96 | + | unlock, err := m.lockRepo(ctx, name) | |
| 97 | + | if err != nil { | |
| 98 | + | return domain.MergeRequest{}, err | |
| 99 | + | } | |
| 100 | + | defer unlock() | |
| 101 | + | mr, err := m.store.Get(ctx, name, number) | |
| 102 | + | if err != nil { | |
| 103 | + | return domain.MergeRequest{}, err | |
| 104 | + | } | |
| 105 | + | if !mr.IsOpen() { | |
| 106 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 107 | + | } | |
| 108 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 109 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 110 | + | } | |
| 111 | + | if mr.AutoMerge == nil { | |
| 112 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed) | |
| 113 | + | } | |
| 114 | + | mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p)) | |
| 115 | + | if err != nil { | |
| 116 | + | return domain.MergeRequest{}, err | |
| 117 | + | } | |
| 118 | + | return m.withHead(ctx, mr) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // cancelAuto takes the merge lock and only cancels the intent evaluated by the | |
| 122 | + | // caller. A concurrent re-enable, close or merge must not be undone. | |
| 123 | + | func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error { | |
| 124 | + | unlock, err := m.lockRepo(ctx, mr.Repo) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | defer unlock() | |
| 129 | + | _, err = m.cancelAutoLocked(ctx, mr, reason) | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) { | |
| 134 | + | for range maxWriteRetries { | |
| 135 | + | mr, err := m.store.Get(ctx, expected.Repo, expected.Number) | |
| 136 | + | if err != nil { | |
| 137 | + | return domain.MergeRequest{}, err | |
| 138 | + | } | |
| 139 | + | if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge { | |
| 140 | + | return mr, nil | |
| 141 | + | } | |
| 142 | + | repo, err := m.meta.Get(ctx, mr.Repo) | |
| 143 | + | if err != nil { | |
| 144 | + | return domain.MergeRequest{}, err | |
| 145 | + | } | |
| 146 | + | mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC() | |
| 147 | + | if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) { | |
| 148 | + | continue | |
| 149 | + | } else if err != nil { | |
| 150 | + | return domain.MergeRequest{}, err | |
| 151 | + | } | |
| 152 | + | recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt) | |
| 153 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 154 | + | return mr, nil | |
| 155 | + | } | |
| 156 | + | return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict) | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // WithAutoMerge installs the asynchronous evaluator before serving requests. | |||
| 15 | + | func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests { | |||
| 16 | + | m.autoMerge = t | |||
| 17 | + | return m | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // EnableAutoMerge records the actor and current head; re-enabling replaces the | |||
| 21 | + | // previous intent rather than preserving an older actor's authority. | |||
| 22 | + | func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) { | |||
| 23 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |||
| 24 | + | return domain.MergeRequest{}, err | |||
| 25 | + | } | |||
| 26 | + | if m.checks == nil { | |||
| 27 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed) | |||
| 28 | + | } | |||
| 29 | + | mode := params.Mode | |||
| 30 | + | if mode == "" { | |||
| 31 | + | mode = domain.MergeModeFastForward | |||
| 32 | + | } | |||
| 33 | + | if _, err := domain.ParseMergeMode(mode.String()); err != nil { | |||
| 34 | + | return domain.MergeRequest{}, err | |||
| 35 | + | } | |||
| 36 | + | unlock, err := m.lockRepo(ctx, name) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return domain.MergeRequest{}, err | |||
| 39 | + | } | |||
| 40 | + | defer unlock() | |||
| 41 | + | for range maxWriteRetries { | |||
| 42 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return domain.MergeRequest{}, err | |||
| 45 | + | } | |||
| 46 | + | if !mr.IsOpen() { | |||
| 47 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 48 | + | } | |||
| 49 | + | if mr.IsDraft() { | |||
| 50 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |||
| 51 | + | } | |||
| 52 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |||
| 53 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |||
| 54 | + | } | |||
| 55 | + | repo, err := m.meta.Get(ctx, name) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return domain.MergeRequest{}, err | |||
| 58 | + | } | |||
| 59 | + | settings := repo.EffectiveSettings() | |||
| 60 | + | if !settings.AllowAutoMerge { | |||
| 61 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name) | |||
| 62 | + | } | |||
| 63 | + | if !settings.Allows(mode) { | |||
| 64 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |||
| 65 | + | } | |||
| 66 | + | head, err := m.branchOrZero(ctx, name, mr.Source) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return domain.MergeRequest{}, err | |||
| 69 | + | } | |||
| 70 | + | if head.IsZero() { | |||
| 71 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |||
| 72 | + | } | |||
| 73 | + | mr.UpdatedAt = m.clock.Now().UTC() | |||
| 74 | + | mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt} | |||
| 75 | + | if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) { | |||
| 76 | + | continue | |||
| 77 | + | } else if err != nil { | |||
| 78 | + | return domain.MergeRequest{}, err | |||
| 79 | + | } | |||
| 80 | + | recordActivity(ctx, m.meta, name, mr.UpdatedAt) | |||
| 81 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 82 | + | if m.autoMerge != nil { | |||
| 83 | + | m.autoMerge.OnRepoChanged(ctx, name) | |||
| 84 | + | } | |||
| 85 | + | return m.withHead(ctx, mr) | |||
| 86 | + | } | |||
| 87 | + | return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // CancelAutoMerge clears an open request's intent with the same target access | |||
| 91 | + | // required to enable it. | |||
| 92 | + | func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |||
| 93 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |||
| 94 | + | return domain.MergeRequest{}, err | |||
| 95 | + | } | |||
| 96 | + | unlock, err := m.lockRepo(ctx, name) | |||
| 97 | + | if err != nil { | |||
| 98 | + | return domain.MergeRequest{}, err | |||
| 99 | + | } | |||
| 100 | + | defer unlock() | |||
| 101 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return domain.MergeRequest{}, err | |||
| 104 | + | } | |||
| 105 | + | if !mr.IsOpen() { | |||
| 106 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 107 | + | } | |||
| 108 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |||
| 109 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |||
| 110 | + | } | |||
| 111 | + | if mr.AutoMerge == nil { | |||
| 112 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed) | |||
| 113 | + | } | |||
| 114 | + | mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p)) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return domain.MergeRequest{}, err | |||
| 117 | + | } | |||
| 118 | + | return m.withHead(ctx, mr) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // cancelAuto takes the merge lock and only cancels the intent evaluated by the | |||
| 122 | + | // caller. A concurrent re-enable, close or merge must not be undone. | |||
| 123 | + | func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error { | |||
| 124 | + | unlock, err := m.lockRepo(ctx, mr.Repo) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | defer unlock() | |||
| 129 | + | _, err = m.cancelAutoLocked(ctx, mr, reason) | |||
| 130 | + | return err | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) { | |||
| 134 | + | for range maxWriteRetries { | |||
| 135 | + | mr, err := m.store.Get(ctx, expected.Repo, expected.Number) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return domain.MergeRequest{}, err | |||
| 138 | + | } | |||
| 139 | + | if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge { | |||
| 140 | + | return mr, nil | |||
| 141 | + | } | |||
| 142 | + | repo, err := m.meta.Get(ctx, mr.Repo) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return domain.MergeRequest{}, err | |||
| 145 | + | } | |||
| 146 | + | mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC() | |||
| 147 | + | if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) { | |||
| 148 | + | continue | |||
| 149 | + | } else if err != nil { | |||
| 150 | + | return domain.MergeRequest{}, err | |||
| 151 | + | } | |||
| 152 | + | recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt) | |||
| 153 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 154 | + | return mr, nil | |||
| 155 | + | } | |||
| 156 | + | return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict) | |||
| 157 | + | } | |||
M internal/usecase/repos.go +10 -6
| @@ -17,12 +17,13 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Repos are the repository use cases. | |
| 19 | 19 | type Repos struct { | |
| 20 | - | meta ports.RepoMetaStore | |
| 21 | - | git ports.GitRepos | |
| 22 | - | bus ports.EventBus | |
| 23 | - | clock ports.Clock | |
| 24 | - | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 25 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 20 | + | meta ports.RepoMetaStore | |
| 21 | + | git ports.GitRepos | |
| 22 | + | bus ports.EventBus | |
| 23 | + | clock ports.Clock | |
| 24 | + | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 25 | + | autoMerge ports.AutoMergeTrigger | |
| 26 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -233,3 +234,6 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||
| 233 | 234 | } | |
| 234 | 235 | return rep, nil | |
| 235 | 236 | } | |
| 237 | + | ||
| 238 | + | // WithAutoMerge installs the settings-change trigger before serving requests. | |
| 239 | + | func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r } | |
| @@ -17,12 +17,13 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Repos are the repository use cases. | 18 | // Repos are the repository use cases. | ||
| 19 | type Repos struct { | 19 | type Repos struct { | ||
| 20 | - | meta ports.RepoMetaStore | 20 | + | meta ports.RepoMetaStore |
| 21 | - | git ports.GitRepos | 21 | + | git ports.GitRepos |
| 22 | - | bus ports.EventBus | 22 | + | bus ports.EventBus |
| 23 | - | clock ports.Clock | 23 | + | clock ports.Clock |
| 24 | - | ci ports.CIRunCleaner // nil: no CI logs to remove | 24 | + | ci ports.CIRunCleaner // nil: no CI logs to remove |
| 25 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | 25 | + | autoMerge ports.AutoMergeTrigger |
| 26 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | var _ ports.RepoService = (*Repos)(nil) | 29 | var _ ports.RepoService = (*Repos)(nil) | ||
| @@ -233,3 +234,6 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||||
| 233 | } | 234 | } | ||
| 234 | return rep, nil | 235 | return rep, nil | ||
| 235 | } | 236 | } | ||
| 237 | + | ||||
| 238 | + | // WithAutoMerge installs the settings-change trigger before serving requests. | |||
| 239 | + | func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r } | |||
M internal/usecase/repos_write.go +9 -3
| @@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 62 | 62 | } | |
| 63 | 63 | before := repo | |
| 64 | 64 | if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil && | |
| 65 | - | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil { | |
| 65 | + | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil { | |
| 66 | 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | |
| 67 | 67 | } | |
| 68 | 68 | if params.AllowedMergeModes != nil { | |
| @@ -116,11 +116,14 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 116 | 116 | // branch was checked above, so the remaining causes are a vanished | |
| 117 | 117 | // repository or an I/O failure. | |
| 118 | 118 | metaDone := false | |
| 119 | - | if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil { | |
| 119 | + | if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil { | |
| 120 | 120 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | |
| 121 | 121 | return domain.Repo{}, err | |
| 122 | 122 | } | |
| 123 | 123 | metaDone = true | |
| 124 | + | if r.autoMerge != nil { | |
| 125 | + | r.autoMerge.OnRepoChanged(ctx, name) | |
| 126 | + | } | |
| 124 | 127 | } | |
| 125 | 128 | var headErr error | |
| 126 | 129 | if params.DefaultBranch != nil { | |
| @@ -142,7 +145,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 142 | 145 | Bool("topics", params.Topics != nil). | |
| 143 | 146 | Bool("visibility", params.Visibility != nil). | |
| 144 | 147 | Bool("default_branch", params.DefaultBranch != nil). | |
| 145 | - | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil). | |
| 148 | + | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil). | |
| 146 | 149 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 147 | 150 | Msg("repository updated") | |
| 148 | 151 | r.bus.Publish(ctx, domain.RepoUpdated{ | |
| @@ -175,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 175 | 178 | if params.FailingChecksBlock != nil { | |
| 176 | 179 | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |
| 177 | 180 | } | |
| 181 | + | if params.AllowAutoMerge != nil { | |
| 182 | + | repo.Settings.AllowAutoMerge = *params.AllowAutoMerge | |
| 183 | + | } | |
| 178 | 184 | if params.Issues != nil { | |
| 179 | 185 | repo.Settings.Issues = *params.Issues | |
| 180 | 186 | } | |
| @@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 62 | } | 62 | } | ||
| 63 | before := repo | 63 | before := repo | ||
| 64 | if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil && | 64 | if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil && | ||
| 65 | - | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil { | 65 | + | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil { |
| 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | ||
| 67 | } | 67 | } | ||
| 68 | if params.AllowedMergeModes != nil { | 68 | if params.AllowedMergeModes != nil { | ||
| @@ -116,11 +116,14 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 116 | // branch was checked above, so the remaining causes are a vanished | 116 | // branch was checked above, so the remaining causes are a vanished | ||
| 117 | // repository or an I/O failure. | 117 | // repository or an I/O failure. | ||
| 118 | metaDone := false | 118 | metaDone := false | ||
| 119 | - | if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil { | 119 | + | if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil { |
| 120 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | 120 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | ||
| 121 | return domain.Repo{}, err | 121 | return domain.Repo{}, err | ||
| 122 | } | 122 | } | ||
| 123 | metaDone = true | 123 | metaDone = true | ||
| 124 | + | if r.autoMerge != nil { | |||
| 125 | + | r.autoMerge.OnRepoChanged(ctx, name) | |||
| 126 | + | } | |||
| 124 | } | 127 | } | ||
| 125 | var headErr error | 128 | var headErr error | ||
| 126 | if params.DefaultBranch != nil { | 129 | if params.DefaultBranch != nil { | ||
| @@ -142,7 +145,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 142 | Bool("topics", params.Topics != nil). | 145 | Bool("topics", params.Topics != nil). | ||
| 143 | Bool("visibility", params.Visibility != nil). | 146 | Bool("visibility", params.Visibility != nil). | ||
| 144 | Bool("default_branch", params.DefaultBranch != nil). | 147 | Bool("default_branch", params.DefaultBranch != nil). | ||
| 145 | - | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil). | 148 | + | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil). |
| 146 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | 149 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | ||
| 147 | Msg("repository updated") | 150 | Msg("repository updated") | ||
| 148 | r.bus.Publish(ctx, domain.RepoUpdated{ | 151 | r.bus.Publish(ctx, domain.RepoUpdated{ | ||
| @@ -175,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||||
| 175 | if params.FailingChecksBlock != nil { | 178 | if params.FailingChecksBlock != nil { | ||
| 176 | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | 179 | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | ||
| 177 | } | 180 | } | ||
| 181 | + | if params.AllowAutoMerge != nil { | |||
| 182 | + | repo.Settings.AllowAutoMerge = *params.AllowAutoMerge | |||
| 183 | + | } | |||
| 178 | if params.Issues != nil { | 184 | if params.Issues != nil { | ||
| 179 | repo.Settings.Issues = *params.Issues | 185 | repo.Settings.Issues = *params.Issues | ||
| 180 | } | 186 | } | ||
M internal/usecase/repos_write_test.go +17 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "bnema.dev/ermite/internal/domain" | |
| 17 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | 19 | "bnema.dev/ermite/internal/usecase" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| @@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) { | |||
| 27 | 28 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"} | |
| 28 | 29 | admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools") | |
| 29 | 30 | ||
| 31 | + | t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) { | |
| 32 | + | f := newFixture(t) | |
| 33 | + | trigger := mocks.NewAutoMergeTrigger(t) | |
| 34 | + | f.repos.WithAutoMerge(trigger) | |
| 35 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 36 | + | want := priv | |
| 37 | + | want.Settings = domain.DefaultRepoSettings() | |
| 38 | + | want.Settings.AllowAutoMerge = true | |
| 39 | + | expectRepoMutation(t, f.meta, priv, want) | |
| 40 | + | trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once() | |
| 41 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 42 | + | got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)}) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.True(t, got.EffectiveSettings().AllowAutoMerge) | |
| 45 | + | }) | |
| 46 | + | ||
| 30 | 47 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | |
| 31 | 48 | t.Parallel() | |
| 32 | 49 | f := newFixture(t) | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 17 | "bnema.dev/ermite/internal/ports" | 17 | "bnema.dev/ermite/internal/ports" | ||
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 18 | "bnema.dev/ermite/internal/usecase" | 19 | "bnema.dev/ermite/internal/usecase" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| @@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) { | |||||
| 27 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"} | 28 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"} | ||
| 28 | admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools") | 29 | admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools") | ||
| 29 | 30 | ||||
| 31 | + | t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) { | |||
| 32 | + | f := newFixture(t) | |||
| 33 | + | trigger := mocks.NewAutoMergeTrigger(t) | |||
| 34 | + | f.repos.WithAutoMerge(trigger) | |||
| 35 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |||
| 36 | + | want := priv | |||
| 37 | + | want.Settings = domain.DefaultRepoSettings() | |||
| 38 | + | want.Settings.AllowAutoMerge = true | |||
| 39 | + | expectRepoMutation(t, f.meta, priv, want) | |||
| 40 | + | trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once() | |||
| 41 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |||
| 42 | + | got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)}) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.True(t, got.EffectiveSettings().AllowAutoMerge) | |||
| 45 | + | }) | |||
| 46 | + | ||||
| 30 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | 47 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | ||
| 31 | t.Parallel() | 48 | t.Parallel() | ||
| 32 | f := newFixture(t) | 49 | f := newFixture(t) | ||