detached 8dbcdb5

fix(ci): refuse a merge when the checks of the head are too many to read

CommitChecks returned the first 1,000 checks without saying it cut the list,
so the merge rule could pass on a part of them. It now fails, and the merge is
refused with a precondition error.
commit
8dbcdb5076c8a0cb7c67557a942f3494dc22f5f3
author
brice · · 2026-10-12 19:13 UTC
parent
b7f6252
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +41 -5
M internal/ports/ci.go +3 -2
@@ -103,8 +103,9 @@ type CIRunCleaner interface {
103103 // CIChecksReader is the read side of the checks, for the merge rule and the
104104 // pages that show them. *usecase.CI implements it.
105105 type CIChecksReader interface {
106- // CommitChecks returns the checks of a commit, at most 1,000, without
107- // access control (the caller has checked it).
106+ // CommitChecks returns the checks of a commit, without access control (the
107+ // caller has checked it). More than 1,000 is domain.ErrResourceExhausted,
108+ // not a part of them: a rule must not decide on some of the checks.
108109 CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)
109110 }
110111
@@ -103,8 +103,9 @@ type CIRunCleaner interface {
103 // CIChecksReader is the read side of the checks, for the merge rule and the103 // CIChecksReader is the read side of the checks, for the merge rule and the
104 // pages that show them. *usecase.CI implements it.104 // pages that show them. *usecase.CI implements it.
105 type CIChecksReader interface {105 type CIChecksReader interface {
106- // CommitChecks returns the checks of a commit, at most 1,000, without106+ // CommitChecks returns the checks of a commit, without access control (the
107- // access control (the caller has checked it).107+ // caller has checked it). More than 1,000 is domain.ErrResourceExhausted,
108+ // not a part of them: a rule must not decide on some of the checks.
108 CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)109 CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)
109 }110 }
110 111
M internal/usecase/ci_runner_unit_test.go +16 -0
@@ -269,3 +269,19 @@ func TestGetJobLogRevalidatesABusyStream(t *testing.T) {
269269 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked token stops a busy stream")
270270 assert.GreaterOrEqual(t, sent, 2)
271271 }
272+
273+// S9: more than 1,000 checks on a commit is an error for the merge rule, not a
274+// list cut at 1,000.
275+func TestCommitChecksRefusesATruncatedList(t *testing.T) {
276+ t.Parallel()
277+ u := newCIUnit(t, nil)
278+ repo := name(t, "proj")
279+ u.store.EXPECT().ListChecks(mock.Anything, repo, "abc", usecase.MaxCIChecks).Return(make([]domain.CICheck, usecase.MaxCIChecks), true, nil).Once()
280+ _, err := u.ci.CommitChecks(context.Background(), repo, "abc")
281+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
282+
283+ u.store.EXPECT().ListChecks(mock.Anything, repo, "def", usecase.MaxCIChecks).Return([]domain.CICheck{{Name: "a"}}, false, nil).Once()
284+ checks, err := u.ci.CommitChecks(context.Background(), repo, "def")
285+ require.NoError(t, err)
286+ assert.Len(t, checks, 1)
287+}
@@ -269,3 +269,19 @@ func TestGetJobLogRevalidatesABusyStream(t *testing.T) {
269 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked token stops a busy stream")269 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked token stops a busy stream")
270 assert.GreaterOrEqual(t, sent, 2)270 assert.GreaterOrEqual(t, sent, 2)
271 }271 }
272+
273+// S9: more than 1,000 checks on a commit is an error for the merge rule, not a
274+// list cut at 1,000.
275+func TestCommitChecksRefusesATruncatedList(t *testing.T) {
276+ t.Parallel()
277+ u := newCIUnit(t, nil)
278+ repo := name(t, "proj")
279+ u.store.EXPECT().ListChecks(mock.Anything, repo, "abc", usecase.MaxCIChecks).Return(make([]domain.CICheck, usecase.MaxCIChecks), true, nil).Once()
280+ _, err := u.ci.CommitChecks(context.Background(), repo, "abc")
281+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
282+
283+ u.store.EXPECT().ListChecks(mock.Anything, repo, "def", usecase.MaxCIChecks).Return([]domain.CICheck{{Name: "a"}}, false, nil).Once()
284+ checks, err := u.ci.CommitChecks(context.Background(), repo, "def")
285+ require.NoError(t, err)
286+ assert.Len(t, checks, 1)
287+}
M internal/usecase/ci_runs.go +11 -3
@@ -466,10 +466,18 @@ func (w *wfCache) drop(runID int64) {
466466
467467 // --- checks ---
468468
469-// CommitChecks returns the checks of a commit, without access control.
469+// CommitChecks returns the checks of a commit, without access control. A
470+// commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted):
471+// the merge rule cannot answer from a part of them.
470472 func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {
471- checks, _, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)
472- return checks, err
473+ checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)
474+ if err != nil {
475+ return nil, err
476+ }
477+ if more {
478+ return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks)
479+ }
480+ return checks, nil
473481 }
474482
475483 // ListChecks returns the checks of a commit: the one of each job of the latest
@@ -466,10 +466,18 @@ func (w *wfCache) drop(runID int64) {
466 466
467 // --- checks ---467 // --- checks ---
468 468
469-// CommitChecks returns the checks of a commit, without access control.469+// CommitChecks returns the checks of a commit, without access control. A
470+// commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted):
471+// the merge rule cannot answer from a part of them.
470 func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {472 func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {
471- checks, _, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)473+ checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)
472- return checks, err474+ if err != nil {
475+ return nil, err
476+ }
477+ if more {
478+ return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks)
479+ }
480+ return checks, nil
473 }481 }
474 482
475 // ListChecks returns the checks of a commit: the one of each job of the latest483 // ListChecks returns the checks of a commit: the one of each job of the latest
M internal/usecase/ci_unit_test.go +8 -0
@@ -91,6 +91,14 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
9191 assert.Contains(t, err.Error(), tt.blocked)
9292 })
9393 }
94+ t.Run("too many checks to tell", func(t *testing.T) {
95+ t.Parallel()
96+ m := newMergeUnit(t, true)
97+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
98+ err := m.merge()
99+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
100+ assert.Contains(t, err.Error(), "too many checks")
101+ })
94102 // The cases where the merge goes on (no check, all passing, rule off) need
95103 // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
96104 }
@@ -91,6 +91,14 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
91 assert.Contains(t, err.Error(), tt.blocked)91 assert.Contains(t, err.Error(), tt.blocked)
92 })92 })
93 }93 }
94+ t.Run("too many checks to tell", func(t *testing.T) {
95+ t.Parallel()
96+ m := newMergeUnit(t, true)
97+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
98+ err := m.merge()
99+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
100+ assert.Contains(t, err.Error(), "too many checks")
101+ })
94 // The cases where the merge goes on (no check, all passing, rule off) need102 // The cases where the merge goes on (no check, all passing, rule off) need
95 // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.103 // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
96 }104 }
M internal/usecase/mergerequests.go +3 -0
@@ -821,6 +821,9 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h
821821 return nil
822822 }
823823 checks, err := m.checks.CommitChecks(ctx, name, head.String())
824+ if errors.Is(err, domain.ErrResourceExhausted) {
825+ return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
826+ }
824827 if err != nil {
825828 return fmt.Errorf("read the checks of %s: %w", head, err)
826829 }
@@ -821,6 +821,9 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h
821 return nil821 return nil
822 }822 }
823 checks, err := m.checks.CommitChecks(ctx, name, head.String())823 checks, err := m.checks.CommitChecks(ctx, name, head.String())
824+ if errors.Is(err, domain.ErrResourceExhausted) {
825+ return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
826+ }
824 if err != nil {827 if err != nil {
825 return fmt.Errorf("read the checks of %s: %w", head, err)828 return fmt.Errorf("read the checks of %s: %w", head, err)
826 }829 }

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close