tenseleyflow/shithub / f0bdb52

Browse files

S24: checks orchestrator (create/update/rollup/required-eval/stale) + tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f0bdb5222e91c21f1eab32dda2732e75ad6912b0
Parents
0bf7e12
Tree
c9f812d

8 changed files

StatusFile+-
A internal/checks/checks.go 73 0
A internal/checks/checks_test.go 276 0
A internal/checks/create.go 176 0
A internal/checks/required_eval.go 96 0
A internal/checks/stale.go 36 0
A internal/checks/suite_rollup.go 99 0
A internal/checks/suite_rollup_test.go 100 0
A internal/checks/update.go 157 0
internal/checks/checks.goadded
@@ -0,0 +1,73 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package checks owns CI check-suite + check-run orchestration. The
4
+// API layer (internal/web/handlers/api/checks.go) calls into Create
5
+// and Update; the merge gate at internal/pulls/pulls.go calls
6
+// EvaluateRequiredChecks.
7
+//
8
+// No work in this package executes user code — shithub is a
9
+// receiver of check status from external systems (and, post-MVP,
10
+// shithub Actions). The API surface is GitHub-shaped so existing CI
11
+// adapters port cleanly.
12
+package checks
13
+
14
+import (
15
+	"errors"
16
+	"log/slog"
17
+
18
+	"github.com/jackc/pgx/v5/pgxpool"
19
+)
20
+
21
+// Deps wires the package against the runtime.
22
+type Deps struct {
23
+	Pool   *pgxpool.Pool
24
+	Logger *slog.Logger
25
+}
26
+
27
+// Output is the JSON shape stored in `check_runs.output`. Matches
28
+// GitHub's `output` shape: title + summary + text. Bodies are bounded
29
+// at the orchestrator (256 KiB on text, 64 KiB on summary) per spec.
30
+type Output struct {
31
+	Title   string `json:"title,omitempty"`
32
+	Summary string `json:"summary,omitempty"`
33
+	Text    string `json:"text,omitempty"`
34
+}
35
+
36
+// MaxOutputTextBytes / MaxOutputSummaryBytes cap the output payload
37
+// per spec section "Open questions".
38
+const (
39
+	MaxOutputTextBytes    = 256 * 1024
40
+	MaxOutputSummaryBytes = 64 * 1024
41
+)
42
+
43
+// Errors surfaced to API + handlers.
44
+var (
45
+	ErrEmptyName              = errors.New("checks: name is required")
46
+	ErrNameTooLong            = errors.New("checks: name too long (max 200)")
47
+	ErrInvalidStatus          = errors.New("checks: status must be queued, in_progress, completed, or pending")
48
+	ErrInvalidConclusion      = errors.New("checks: invalid conclusion")
49
+	ErrCompletedNeedsConclusion = errors.New("checks: completed status requires conclusion")
50
+	ErrOutputTextTooLarge     = errors.New("checks: output.text exceeds 256 KiB cap")
51
+	ErrOutputSummaryTooLarge  = errors.New("checks: output.summary exceeds 64 KiB cap")
52
+	ErrShortHeadSHA           = errors.New("checks: head_sha must be at least 7 hex chars")
53
+	ErrCheckRunNotFound       = errors.New("checks: run not found")
54
+	ErrSuiteNotFound          = errors.New("checks: suite not found")
55
+)
56
+
57
+// validStatus / validConclusion mirror the Postgres enums.
58
+func validStatus(s string) bool {
59
+	switch s {
60
+	case "queued", "in_progress", "completed", "pending":
61
+		return true
62
+	}
63
+	return false
64
+}
65
+
66
+func validConclusion(s string) bool {
67
+	switch s {
68
+	case "success", "failure", "neutral", "cancelled",
69
+		"skipped", "timed_out", "action_required", "stale":
70
+		return true
71
+	}
72
+	return false
73
+}
internal/checks/checks_test.goadded
@@ -0,0 +1,276 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"strings"
10
+	"testing"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+	"github.com/jackc/pgx/v5/pgxpool"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/checks"
17
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
18
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21
+)
22
+
23
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24
+	"AAAAAAAAAAAAAAAA$" +
25
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
+
27
+type fx struct {
28
+	pool   *pgxpool.Pool
29
+	deps   checks.Deps
30
+	repoID int64
31
+}
32
+
33
+func setup(t *testing.T) fx {
34
+	t.Helper()
35
+	pool := dbtest.NewTestDB(t)
36
+	ctx := context.Background()
37
+
38
+	user, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
39
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
40
+	})
41
+	if err != nil {
42
+		t.Fatalf("CreateUser: %v", err)
43
+	}
44
+	repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
45
+		OwnerUserID:   pgtype.Int8{Int64: user.ID, Valid: true},
46
+		Name:          "demo",
47
+		DefaultBranch: "trunk",
48
+		Visibility:    reposdb.RepoVisibilityPublic,
49
+	})
50
+	if err != nil {
51
+		t.Fatalf("CreateRepo: %v", err)
52
+	}
53
+	return fx{
54
+		pool:   pool,
55
+		deps:   checks.Deps{Pool: pool, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))},
56
+		repoID: repo.ID,
57
+	}
58
+}
59
+
60
+func TestCreate_AutoCreatesSuite(t *testing.T) {
61
+	f := setup(t)
62
+	ctx := context.Background()
63
+	run, err := checks.Create(ctx, f.deps, checks.CreateParams{
64
+		RepoID:  f.repoID,
65
+		HeadSHA: strings.Repeat("a", 40),
66
+		Name:    "lint",
67
+	})
68
+	if err != nil {
69
+		t.Fatalf("Create: %v", err)
70
+	}
71
+	if run.SuiteID == 0 {
72
+		t.Errorf("SuiteID should be set")
73
+	}
74
+	if run.Status != checksdb.CheckStatusQueued {
75
+		t.Errorf("default status: got %s, want queued", run.Status)
76
+	}
77
+	suites, _ := checksdb.New().ListCheckSuitesForCommit(ctx, f.pool, checksdb.ListCheckSuitesForCommitParams{
78
+		RepoID: f.repoID, HeadSha: run.HeadSha,
79
+	})
80
+	if len(suites) != 1 || suites[0].AppSlug != "external" {
81
+		t.Errorf("expected one external suite, got %v", suites)
82
+	}
83
+}
84
+
85
+func TestCreate_IdempotentByExternalID(t *testing.T) {
86
+	f := setup(t)
87
+	ctx := context.Background()
88
+	args := checks.CreateParams{
89
+		RepoID:     f.repoID,
90
+		HeadSHA:    strings.Repeat("a", 40),
91
+		Name:       "lint",
92
+		ExternalID: "ci-job-42",
93
+	}
94
+	a, err := checks.Create(ctx, f.deps, args)
95
+	if err != nil {
96
+		t.Fatalf("first Create: %v", err)
97
+	}
98
+	b, err := checks.Create(ctx, f.deps, args)
99
+	if err != nil {
100
+		t.Fatalf("second Create: %v", err)
101
+	}
102
+	if a.ID != b.ID {
103
+		t.Errorf("expected same id (idempotent), got %d vs %d", a.ID, b.ID)
104
+	}
105
+}
106
+
107
+func TestCreate_RequiresConclusionWhenCompleted(t *testing.T) {
108
+	f := setup(t)
109
+	_, err := checks.Create(context.Background(), f.deps, checks.CreateParams{
110
+		RepoID:  f.repoID,
111
+		HeadSHA: strings.Repeat("a", 40),
112
+		Name:    "lint",
113
+		Status:  "completed",
114
+	})
115
+	if err == nil {
116
+		t.Errorf("expected ErrCompletedNeedsConclusion, got nil")
117
+	}
118
+}
119
+
120
+func TestCreate_RejectsShortHeadSHA(t *testing.T) {
121
+	f := setup(t)
122
+	_, err := checks.Create(context.Background(), f.deps, checks.CreateParams{
123
+		RepoID:  f.repoID,
124
+		HeadSHA: "abc",
125
+		Name:    "lint",
126
+	})
127
+	if err == nil {
128
+		t.Errorf("expected ErrShortHeadSHA, got nil")
129
+	}
130
+}
131
+
132
+func TestCreate_RejectsTooLargeOutput(t *testing.T) {
133
+	f := setup(t)
134
+	big := strings.Repeat("x", checks.MaxOutputTextBytes+1)
135
+	_, err := checks.Create(context.Background(), f.deps, checks.CreateParams{
136
+		RepoID:  f.repoID,
137
+		HeadSHA: strings.Repeat("a", 40),
138
+		Name:    "lint",
139
+		Output:  checks.Output{Text: big},
140
+	})
141
+	if err == nil {
142
+		t.Errorf("expected ErrOutputTextTooLarge, got nil")
143
+	}
144
+}
145
+
146
+func TestUpdate_RollsUpSuiteConclusion(t *testing.T) {
147
+	f := setup(t)
148
+	ctx := context.Background()
149
+	sha := strings.Repeat("a", 40)
150
+	a, _ := checks.Create(ctx, f.deps, checks.CreateParams{
151
+		RepoID: f.repoID, HeadSHA: sha, Name: "lint",
152
+	})
153
+	b, _ := checks.Create(ctx, f.deps, checks.CreateParams{
154
+		RepoID: f.repoID, HeadSHA: sha, Name: "test",
155
+	})
156
+	// Complete both as success.
157
+	for _, id := range []int64{a.ID, b.ID} {
158
+		if _, err := checks.Update(ctx, f.deps, checks.UpdateParams{
159
+			RunID:         id,
160
+			HasStatus:     true,
161
+			Status:        "completed",
162
+			HasConclusion: true,
163
+			Conclusion:    "success",
164
+		}); err != nil {
165
+			t.Fatalf("Update %d: %v", id, err)
166
+		}
167
+	}
168
+	suite, _ := checksdb.New().GetCheckSuite(ctx, f.pool, a.SuiteID)
169
+	if suite.Status != checksdb.CheckStatusCompleted {
170
+		t.Errorf("suite status: got %s, want completed", suite.Status)
171
+	}
172
+	if !suite.Conclusion.Valid || suite.Conclusion.CheckConclusion != checksdb.CheckConclusionSuccess {
173
+		t.Errorf("suite conclusion: got %+v, want success", suite.Conclusion)
174
+	}
175
+}
176
+
177
+func TestEvaluateRequiredChecks_NoRequired(t *testing.T) {
178
+	f := setup(t)
179
+	got, err := checks.EvaluateRequiredChecks(context.Background(), f.pool, checks.GateInputs{
180
+		RepoID: f.repoID, HeadSHA: strings.Repeat("a", 40),
181
+	})
182
+	if err != nil || !got.Satisfied {
183
+		t.Errorf("no required → satisfied; got %+v err=%v", got, err)
184
+	}
185
+}
186
+
187
+func TestEvaluateRequiredChecks_BlocksThenSatisfies(t *testing.T) {
188
+	f := setup(t)
189
+	ctx := context.Background()
190
+	sha := strings.Repeat("a", 40)
191
+	in := checks.GateInputs{
192
+		RepoID:        f.repoID,
193
+		HeadSHA:       sha,
194
+		RequiredNames: []string{"lint"},
195
+	}
196
+
197
+	// No run yet → blocked.
198
+	got, _ := checks.EvaluateRequiredChecks(ctx, f.pool, in)
199
+	if got.Satisfied {
200
+		t.Errorf("no run yet, expected blocked")
201
+	}
202
+	// Queued run → still blocked.
203
+	run, _ := checks.Create(ctx, f.deps, checks.CreateParams{
204
+		RepoID: f.repoID, HeadSHA: sha, Name: "lint",
205
+	})
206
+	got, _ = checks.EvaluateRequiredChecks(ctx, f.pool, in)
207
+	if got.Satisfied {
208
+		t.Errorf("queued run, expected blocked")
209
+	}
210
+	// Complete with failure → still blocked.
211
+	_, _ = checks.Update(ctx, f.deps, checks.UpdateParams{
212
+		RunID: run.ID, HasStatus: true, Status: "completed",
213
+		HasConclusion: true, Conclusion: "failure",
214
+	})
215
+	got, _ = checks.EvaluateRequiredChecks(ctx, f.pool, in)
216
+	if got.Satisfied {
217
+		t.Errorf("failure, expected blocked")
218
+	}
219
+	// Switch to success → satisfied.
220
+	_, _ = checks.Update(ctx, f.deps, checks.UpdateParams{
221
+		RunID: run.ID, HasStatus: true, Status: "completed",
222
+		HasConclusion: true, Conclusion: "success",
223
+	})
224
+	got, _ = checks.EvaluateRequiredChecks(ctx, f.pool, in)
225
+	if !got.Satisfied {
226
+		t.Errorf("success, expected satisfied; reason=%q missing=%v", got.Reason, got.Missing)
227
+	}
228
+}
229
+
230
+func TestStaleOnPush_MarksSuites(t *testing.T) {
231
+	f := setup(t)
232
+	ctx := context.Background()
233
+	prev := strings.Repeat("a", 40)
234
+	// Queue a run on prev → suite status=queued.
235
+	if _, err := checks.Create(ctx, f.deps, checks.CreateParams{
236
+		RepoID: f.repoID, HeadSHA: prev, Name: "lint",
237
+	}); err != nil {
238
+		t.Fatalf("Create: %v", err)
239
+	}
240
+	n, err := checks.MarkStaleForPreviousHead(ctx, f.deps, f.repoID, prev)
241
+	if err != nil {
242
+		t.Fatalf("MarkStaleForPreviousHead: %v", err)
243
+	}
244
+	if n != 1 {
245
+		t.Errorf("expected 1 suite marked stale, got %d", n)
246
+	}
247
+	suites, _ := checksdb.New().ListCheckSuitesForCommit(ctx, f.pool, checksdb.ListCheckSuitesForCommitParams{
248
+		RepoID: f.repoID, HeadSha: prev,
249
+	})
250
+	if len(suites) != 1 || !suites[0].Conclusion.Valid ||
251
+		suites[0].Conclusion.CheckConclusion != checksdb.CheckConclusionStale {
252
+		t.Errorf("post-stale: %+v", suites)
253
+	}
254
+}
255
+
256
+func TestUpdate_TimestampsRoundTrip(t *testing.T) {
257
+	f := setup(t)
258
+	ctx := context.Background()
259
+	when := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
260
+	run, _ := checks.Create(ctx, f.deps, checks.CreateParams{
261
+		RepoID: f.repoID, HeadSHA: strings.Repeat("a", 40), Name: "lint",
262
+	})
263
+	if _, err := checks.Update(ctx, f.deps, checks.UpdateParams{
264
+		RunID:          run.ID,
265
+		HasStatus:      true, Status: "completed",
266
+		HasConclusion:  true, Conclusion: "success",
267
+		HasStartedAt:   true, StartedAt: when,
268
+		HasCompletedAt: true, CompletedAt: when.Add(30 * time.Second),
269
+	}); err != nil {
270
+		t.Fatalf("Update: %v", err)
271
+	}
272
+	got, _ := checksdb.New().GetCheckRun(ctx, f.pool, run.ID)
273
+	if !got.StartedAt.Valid || !got.CompletedAt.Valid {
274
+		t.Errorf("timestamps not set: %+v", got)
275
+	}
276
+}
internal/checks/create.goadded
@@ -0,0 +1,176 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"fmt"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
17
+)
18
+
19
+// CreateParams describes a check-run create request. Defaults
20
+// (per spec): status='queued', app_slug='external'.
21
+type CreateParams struct {
22
+	RepoID      int64
23
+	HeadSHA     string
24
+	AppSlug     string // "" → "external"
25
+	Name        string
26
+	Status      string // "" → "queued"
27
+	Conclusion  string // optional
28
+	StartedAt   time.Time
29
+	CompletedAt time.Time
30
+	DetailsURL  string
31
+	Output      Output
32
+	ExternalID  string // optional; enables idempotent retry-create
33
+}
34
+
35
+// Create inserts (or returns existing-by-external-id) a check run,
36
+// auto-creating the matching suite. Suite rollup is recomputed inside
37
+// the same tx so the API response reflects the latest derived state.
38
+//
39
+// `external_id` semantics:
40
+//   - When empty: always insert a new run.
41
+//   - When non-empty: lookup by (repo, head_sha, name, external_id);
42
+//     if found, return existing without insert. This makes retries
43
+//     by external CI safe.
44
+func Create(ctx context.Context, deps Deps, p CreateParams) (checksdb.CheckRun, error) {
45
+	if err := validateCreate(&p); err != nil {
46
+		return checksdb.CheckRun{}, err
47
+	}
48
+	q := checksdb.New()
49
+
50
+	// Idempotency check.
51
+	if p.ExternalID != "" {
52
+		existing, err := q.GetCheckRunByExternalID(ctx, deps.Pool, checksdb.GetCheckRunByExternalIDParams{
53
+			RepoID:     p.RepoID,
54
+			HeadSha:    p.HeadSHA,
55
+			Name:       p.Name,
56
+			ExternalID: pgtype.Text{String: p.ExternalID, Valid: true},
57
+		})
58
+		if err == nil {
59
+			return existing, nil
60
+		}
61
+		if !errors.Is(err, pgx.ErrNoRows) {
62
+			return checksdb.CheckRun{}, err
63
+		}
64
+	}
65
+
66
+	tx, err := deps.Pool.Begin(ctx)
67
+	if err != nil {
68
+		return checksdb.CheckRun{}, err
69
+	}
70
+	committed := false
71
+	defer func() {
72
+		if !committed {
73
+			_ = tx.Rollback(ctx)
74
+		}
75
+	}()
76
+
77
+	suite, err := q.GetOrCreateCheckSuite(ctx, tx, checksdb.GetOrCreateCheckSuiteParams{
78
+		RepoID:   p.RepoID,
79
+		HeadSha:  p.HeadSHA,
80
+		AppSlug:  p.AppSlug,
81
+	})
82
+	if err != nil {
83
+		return checksdb.CheckRun{}, fmt.Errorf("suite: %w", err)
84
+	}
85
+
86
+	outputJSON, err := json.Marshal(p.Output)
87
+	if err != nil {
88
+		return checksdb.CheckRun{}, fmt.Errorf("output marshal: %w", err)
89
+	}
90
+
91
+	startedAt := pgtype.Timestamptz{}
92
+	if !p.StartedAt.IsZero() {
93
+		startedAt = pgtype.Timestamptz{Time: p.StartedAt, Valid: true}
94
+	}
95
+	completedAt := pgtype.Timestamptz{}
96
+	if !p.CompletedAt.IsZero() {
97
+		completedAt = pgtype.Timestamptz{Time: p.CompletedAt, Valid: true}
98
+	}
99
+	conclusion := checksdb.NullCheckConclusion{}
100
+	if p.Conclusion != "" {
101
+		conclusion = checksdb.NullCheckConclusion{
102
+			CheckConclusion: checksdb.CheckConclusion(p.Conclusion),
103
+			Valid:           true,
104
+		}
105
+	}
106
+	extID := pgtype.Text{}
107
+	if p.ExternalID != "" {
108
+		extID = pgtype.Text{String: p.ExternalID, Valid: true}
109
+	}
110
+
111
+	run, err := q.CreateCheckRun(ctx, tx, checksdb.CreateCheckRunParams{
112
+		SuiteID:     suite.ID,
113
+		RepoID:      p.RepoID,
114
+		HeadSha:     p.HeadSHA,
115
+		Name:        p.Name,
116
+		Status:      checksdb.CheckStatus(p.Status),
117
+		Conclusion:  conclusion,
118
+		StartedAt:   startedAt,
119
+		CompletedAt: completedAt,
120
+		DetailsUrl:  p.DetailsURL,
121
+		Output:      outputJSON,
122
+		ExternalID:  extID,
123
+	})
124
+	if err != nil {
125
+		return checksdb.CheckRun{}, fmt.Errorf("insert run: %w", err)
126
+	}
127
+
128
+	if err := rollupSuiteInTx(ctx, tx, suite.ID); err != nil {
129
+		return checksdb.CheckRun{}, err
130
+	}
131
+
132
+	if err := tx.Commit(ctx); err != nil {
133
+		return checksdb.CheckRun{}, err
134
+	}
135
+	committed = true
136
+	return run, nil
137
+}
138
+
139
+// validateCreate normalizes defaults + applies per-field bounds.
140
+func validateCreate(p *CreateParams) error {
141
+	p.Name = strings.TrimSpace(p.Name)
142
+	if p.Name == "" {
143
+		return ErrEmptyName
144
+	}
145
+	if len(p.Name) > 200 {
146
+		return ErrNameTooLong
147
+	}
148
+	p.HeadSHA = strings.TrimSpace(p.HeadSHA)
149
+	if len(p.HeadSHA) < 7 || len(p.HeadSHA) > 64 {
150
+		return ErrShortHeadSHA
151
+	}
152
+	p.AppSlug = strings.TrimSpace(p.AppSlug)
153
+	if p.AppSlug == "" {
154
+		p.AppSlug = "external"
155
+	}
156
+	p.Status = strings.TrimSpace(p.Status)
157
+	if p.Status == "" {
158
+		p.Status = "queued"
159
+	}
160
+	if !validStatus(p.Status) {
161
+		return ErrInvalidStatus
162
+	}
163
+	if p.Conclusion != "" && !validConclusion(p.Conclusion) {
164
+		return ErrInvalidConclusion
165
+	}
166
+	if p.Status == "completed" && p.Conclusion == "" {
167
+		return ErrCompletedNeedsConclusion
168
+	}
169
+	if len(p.Output.Text) > MaxOutputTextBytes {
170
+		return ErrOutputTextTooLarge
171
+	}
172
+	if len(p.Output.Summary) > MaxOutputSummaryBytes {
173
+		return ErrOutputSummaryTooLarge
174
+	}
175
+	return nil
176
+}
internal/checks/required_eval.goadded
@@ -0,0 +1,96 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgxpool"
12
+
13
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
14
+)
15
+
16
+// GateInputs is the small struct the merge gate cares about.
17
+type GateInputs struct {
18
+	RepoID         int64
19
+	HeadSHA        string
20
+	RequiredNames  []string // from branch_protection_rules.status_checks_required
21
+}
22
+
23
+// GateResult mirrors the spec's blocked-with-reason vocabulary.
24
+type GateResult struct {
25
+	Satisfied bool
26
+	Reason    string  // "" when Satisfied; otherwise human-readable cause
27
+	// Missing is the subset of RequiredNames that haven't yet succeeded
28
+	// or returned neutral on HeadSHA. Populated for UI tooltips.
29
+	Missing []string
30
+}
31
+
32
+// EvaluateRequiredChecks returns Satisfied=true when every required
33
+// check name has a `success` or `neutral` conclusion on HeadSHA. A
34
+// missing run, an in-progress run, or any other conclusion (failure /
35
+// stale / cancelled / etc.) is unsatisfied.
36
+//
37
+// The evaluator is the same one consulted by `pulls.Mergeability` and
38
+// `pulls.Merge` — composes with `review.Evaluate` at the call site.
39
+func EvaluateRequiredChecks(ctx context.Context, pool *pgxpool.Pool, in GateInputs) (GateResult, error) {
40
+	if len(in.RequiredNames) == 0 {
41
+		return GateResult{Satisfied: true}, nil
42
+	}
43
+	if in.HeadSHA == "" {
44
+		// No head snapshot yet — block, since we can't verify any check
45
+		// passed against an unknown commit.
46
+		return GateResult{
47
+			Reason:  "head SHA not yet recorded for this PR",
48
+			Missing: append([]string(nil), in.RequiredNames...),
49
+		}, nil
50
+	}
51
+	q := checksdb.New()
52
+	missing := []string{}
53
+	for _, name := range in.RequiredNames {
54
+		run, err := q.GetLatestCheckRunByName(ctx, pool, checksdb.GetLatestCheckRunByNameParams{
55
+			RepoID:  in.RepoID,
56
+			HeadSha: in.HeadSHA,
57
+			Name:    name,
58
+		})
59
+		if err != nil {
60
+			if errors.Is(err, pgx.ErrNoRows) {
61
+				missing = append(missing, name)
62
+				continue
63
+			}
64
+			return GateResult{}, fmt.Errorf("required-check lookup %q: %w", name, err)
65
+		}
66
+		if !runSatisfies(run) {
67
+			missing = append(missing, name)
68
+		}
69
+	}
70
+	if len(missing) == 0 {
71
+		return GateResult{Satisfied: true}, nil
72
+	}
73
+	reason := "Required check missing: " + missing[0]
74
+	if len(missing) > 1 {
75
+		reason = fmt.Sprintf("Required checks missing: %v", missing)
76
+	}
77
+	return GateResult{Reason: reason, Missing: missing}, nil
78
+}
79
+
80
+// runSatisfies reports whether a single run "passes" for the
81
+// required-check gate. Matches the spec: `success` and `neutral` on
82
+// the head SHA satisfy; anything else (including in_progress) does
83
+// not.
84
+func runSatisfies(r checksdb.CheckRun) bool {
85
+	if r.Status != checksdb.CheckStatusCompleted {
86
+		return false
87
+	}
88
+	if !r.Conclusion.Valid {
89
+		return false
90
+	}
91
+	switch r.Conclusion.CheckConclusion {
92
+	case checksdb.CheckConclusionSuccess, checksdb.CheckConclusionNeutral:
93
+		return true
94
+	}
95
+	return false
96
+}
internal/checks/stale.goadded
@@ -0,0 +1,36 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"context"
7
+
8
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
9
+)
10
+
11
+// MarkStaleForPreviousHead flips suites on `prevHeadSHA` whose status
12
+// is not yet completed to (completed, conclusion='stale'). Called from
13
+// push:process when a head ref moves AND the matching protection rule
14
+// has `dismiss_stale_status_checks_on_push = true`.
15
+//
16
+// "Status complete + conclusion stale" preserves the audit trail —
17
+// the runs themselves stay readable, but the suite no longer counts
18
+// as in-progress.
19
+func MarkStaleForPreviousHead(ctx context.Context, deps Deps, repoID int64, prevHeadSHA string) (int, error) {
20
+	if prevHeadSHA == "" {
21
+		return 0, nil
22
+	}
23
+	q := checksdb.New()
24
+	ids, err := q.ListCheckSuiteIDsForHead(ctx, deps.Pool, checksdb.ListCheckSuiteIDsForHeadParams{
25
+		RepoID: repoID, HeadSha: prevHeadSHA,
26
+	})
27
+	if err != nil {
28
+		return 0, err
29
+	}
30
+	for _, id := range ids {
31
+		if err := q.MarkCheckSuiteStale(ctx, deps.Pool, id); err != nil {
32
+			return 0, err
33
+		}
34
+	}
35
+	return len(ids), nil
36
+}
internal/checks/suite_rollup.goadded
@@ -0,0 +1,99 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"context"
7
+
8
+	"github.com/jackc/pgx/v5"
9
+
10
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
11
+)
12
+
13
+// rollupSuiteInTx recomputes the suite's status + conclusion from its
14
+// runs and persists the result. Called from Create and Update inside
15
+// the same tx so the API response reflects the latest derived state.
16
+//
17
+// Per spec design table:
18
+//
19
+//	status = 'completed' iff every run is completed
20
+//	conclusion priority (when status='completed'):
21
+//	    failure         > timed_out > cancelled > action_required >
22
+//	    success         > neutral > skipped     > stale
23
+//
24
+// The "first failure-class wins" ordering matches GitHub's roll-up.
25
+// Any non-completed run forces status to 'in_progress' (or keeps the
26
+// existing 'queued' if every run is queued — we treat queued+completed
27
+// as 'in_progress' too, since *something* moved).
28
+func rollupSuiteInTx(ctx context.Context, tx pgx.Tx, suiteID int64) error {
29
+	q := checksdb.New()
30
+	runs, err := q.ListCheckRunsBySuite(ctx, tx, suiteID)
31
+	if err != nil {
32
+		return err
33
+	}
34
+	status, conclusion := DeriveSuiteRollup(runs)
35
+	conclusionParam := checksdb.NullCheckConclusion{}
36
+	if conclusion != "" {
37
+		conclusionParam = checksdb.NullCheckConclusion{
38
+			CheckConclusion: checksdb.CheckConclusion(conclusion),
39
+			Valid:           true,
40
+		}
41
+	}
42
+	return q.UpdateCheckSuiteRollup(ctx, tx, checksdb.UpdateCheckSuiteRollupParams{
43
+		ID:         suiteID,
44
+		Status:     checksdb.CheckStatus(status),
45
+		Conclusion: conclusionParam,
46
+	})
47
+}
48
+
49
+// DeriveSuiteRollup is the pure-function form of the rollup so tests
50
+// can exercise the priority order without a DB. Public so the API
51
+// layer can preview the rollup if needed.
52
+func DeriveSuiteRollup(runs []checksdb.CheckRun) (status, conclusion string) {
53
+	if len(runs) == 0 {
54
+		return "queued", ""
55
+	}
56
+	allCompleted := true
57
+	anyMoved := false
58
+	for _, r := range runs {
59
+		if r.Status != checksdb.CheckStatusCompleted {
60
+			allCompleted = false
61
+		}
62
+		if r.Status != checksdb.CheckStatusQueued {
63
+			anyMoved = true
64
+		}
65
+	}
66
+	if !allCompleted {
67
+		if anyMoved {
68
+			return "in_progress", ""
69
+		}
70
+		return "queued", ""
71
+	}
72
+	// Every run completed → derive aggregate conclusion.
73
+	priority := []string{
74
+		"failure", "timed_out", "cancelled", "action_required",
75
+		"success", "neutral", "skipped", "stale",
76
+	}
77
+	rank := map[string]int{}
78
+	for i, p := range priority {
79
+		rank[p] = i
80
+	}
81
+	bestIdx := -1
82
+	for _, r := range runs {
83
+		if !r.Conclusion.Valid {
84
+			continue
85
+		}
86
+		c := string(r.Conclusion.CheckConclusion)
87
+		idx, ok := rank[c]
88
+		if !ok {
89
+			continue
90
+		}
91
+		if bestIdx < 0 || idx < bestIdx {
92
+			bestIdx = idx
93
+		}
94
+	}
95
+	if bestIdx < 0 {
96
+		return "completed", "neutral"
97
+	}
98
+	return "completed", priority[bestIdx]
99
+}
internal/checks/suite_rollup_test.goadded
@@ -0,0 +1,100 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"testing"
7
+
8
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
9
+)
10
+
11
+// run constructs a CheckRun with just the fields the rollup looks at.
12
+func run(status string, conclusion string) checksdb.CheckRun {
13
+	r := checksdb.CheckRun{Status: checksdb.CheckStatus(status)}
14
+	if conclusion != "" {
15
+		r.Conclusion = checksdb.NullCheckConclusion{
16
+			CheckConclusion: checksdb.CheckConclusion(conclusion),
17
+			Valid:           true,
18
+		}
19
+	}
20
+	return r
21
+}
22
+
23
+func TestDeriveSuiteRollup(t *testing.T) {
24
+	t.Parallel()
25
+	cases := []struct {
26
+		name        string
27
+		runs        []checksdb.CheckRun
28
+		wantStatus  string
29
+		wantConcl   string
30
+	}{
31
+		{
32
+			"empty → queued",
33
+			nil,
34
+			"queued", "",
35
+		},
36
+		{
37
+			"all queued → queued",
38
+			[]checksdb.CheckRun{run("queued", ""), run("queued", "")},
39
+			"queued", "",
40
+		},
41
+		{
42
+			"one in_progress → in_progress",
43
+			[]checksdb.CheckRun{run("queued", ""), run("in_progress", "")},
44
+			"in_progress", "",
45
+		},
46
+		{
47
+			"all completed success → success",
48
+			[]checksdb.CheckRun{run("completed", "success"), run("completed", "success")},
49
+			"completed", "success",
50
+		},
51
+		{
52
+			"any failure → failure",
53
+			[]checksdb.CheckRun{run("completed", "success"), run("completed", "failure")},
54
+			"completed", "failure",
55
+		},
56
+		{
57
+			"failure beats timed_out",
58
+			[]checksdb.CheckRun{run("completed", "timed_out"), run("completed", "failure")},
59
+			"completed", "failure",
60
+		},
61
+		{
62
+			"timed_out beats cancelled beats action_required",
63
+			[]checksdb.CheckRun{run("completed", "cancelled"), run("completed", "timed_out"), run("completed", "action_required")},
64
+			"completed", "timed_out",
65
+		},
66
+		{
67
+			"all neutral → neutral (no successes)",
68
+			[]checksdb.CheckRun{run("completed", "neutral"), run("completed", "neutral")},
69
+			"completed", "neutral",
70
+		},
71
+		{
72
+			"success beats neutral",
73
+			[]checksdb.CheckRun{run("completed", "success"), run("completed", "neutral")},
74
+			"completed", "success",
75
+		},
76
+		{
77
+			"all skipped → skipped",
78
+			[]checksdb.CheckRun{run("completed", "skipped"), run("completed", "skipped")},
79
+			"completed", "skipped",
80
+		},
81
+		{
82
+			"all stale → stale",
83
+			[]checksdb.CheckRun{run("completed", "stale")},
84
+			"completed", "stale",
85
+		},
86
+		{
87
+			"completed without conclusion → completed/neutral fallback",
88
+			[]checksdb.CheckRun{run("completed", "")},
89
+			"completed", "neutral",
90
+		},
91
+	}
92
+	for _, c := range cases {
93
+		t.Run(c.name, func(t *testing.T) {
94
+			gotStatus, gotConcl := DeriveSuiteRollup(c.runs)
95
+			if gotStatus != c.wantStatus || gotConcl != c.wantConcl {
96
+				t.Errorf("got (%s, %s), want (%s, %s)", gotStatus, gotConcl, c.wantStatus, c.wantConcl)
97
+			}
98
+		})
99
+	}
100
+}
internal/checks/update.goadded
@@ -0,0 +1,157 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package checks
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"fmt"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
17
+)
18
+
19
+// UpdateParams describes a check-run patch. Empty optional fields
20
+// keep the existing value (rather than clearing); callers that want
21
+// to clear a field set it to the zero string explicitly via the
22
+// dedicated *Set boolean — keeps the JSON contract simple while
23
+// avoiding pointer-everywhere fields.
24
+//
25
+// Status transitions are validated: queued/pending → in_progress →
26
+// completed; completed requires a conclusion. Re-applying the same
27
+// status is a no-op, which makes external-system retries safe.
28
+type UpdateParams struct {
29
+	RunID       int64
30
+	Status      string
31
+	Conclusion  string
32
+	StartedAt   time.Time
33
+	CompletedAt time.Time
34
+	DetailsURL  string
35
+	Output      Output
36
+
37
+	HasStatus      bool
38
+	HasConclusion  bool
39
+	HasStartedAt   bool
40
+	HasCompletedAt bool
41
+	HasDetailsURL  bool
42
+	HasOutput      bool
43
+}
44
+
45
+// Update patches a check run + recomputes its suite's rollup. Fields
46
+// that aren't marked Has* keep their current value.
47
+func Update(ctx context.Context, deps Deps, p UpdateParams) (checksdb.CheckRun, error) {
48
+	q := checksdb.New()
49
+	cur, err := q.GetCheckRun(ctx, deps.Pool, p.RunID)
50
+	if err != nil {
51
+		if errors.Is(err, pgx.ErrNoRows) {
52
+			return checksdb.CheckRun{}, ErrCheckRunNotFound
53
+		}
54
+		return checksdb.CheckRun{}, err
55
+	}
56
+
57
+	// Resolve effective values.
58
+	status := string(cur.Status)
59
+	if p.HasStatus {
60
+		s := strings.TrimSpace(p.Status)
61
+		if !validStatus(s) {
62
+			return checksdb.CheckRun{}, ErrInvalidStatus
63
+		}
64
+		status = s
65
+	}
66
+	conclusion := ""
67
+	if cur.Conclusion.Valid {
68
+		conclusion = string(cur.Conclusion.CheckConclusion)
69
+	}
70
+	if p.HasConclusion {
71
+		c := strings.TrimSpace(p.Conclusion)
72
+		if c != "" && !validConclusion(c) {
73
+			return checksdb.CheckRun{}, ErrInvalidConclusion
74
+		}
75
+		conclusion = c
76
+	}
77
+	if status == "completed" && conclusion == "" {
78
+		return checksdb.CheckRun{}, ErrCompletedNeedsConclusion
79
+	}
80
+	startedAt := cur.StartedAt
81
+	if p.HasStartedAt {
82
+		if p.StartedAt.IsZero() {
83
+			startedAt = pgtype.Timestamptz{}
84
+		} else {
85
+			startedAt = pgtype.Timestamptz{Time: p.StartedAt, Valid: true}
86
+		}
87
+	}
88
+	completedAt := cur.CompletedAt
89
+	if p.HasCompletedAt {
90
+		if p.CompletedAt.IsZero() {
91
+			completedAt = pgtype.Timestamptz{}
92
+		} else {
93
+			completedAt = pgtype.Timestamptz{Time: p.CompletedAt, Valid: true}
94
+		}
95
+	}
96
+	detailsURL := cur.DetailsUrl
97
+	if p.HasDetailsURL {
98
+		detailsURL = p.DetailsURL
99
+	}
100
+	outputBytes := cur.Output
101
+	if p.HasOutput {
102
+		if len(p.Output.Text) > MaxOutputTextBytes {
103
+			return checksdb.CheckRun{}, ErrOutputTextTooLarge
104
+		}
105
+		if len(p.Output.Summary) > MaxOutputSummaryBytes {
106
+			return checksdb.CheckRun{}, ErrOutputSummaryTooLarge
107
+		}
108
+		outputBytes, err = json.Marshal(p.Output)
109
+		if err != nil {
110
+			return checksdb.CheckRun{}, fmt.Errorf("output marshal: %w", err)
111
+		}
112
+	}
113
+
114
+	tx, err := deps.Pool.Begin(ctx)
115
+	if err != nil {
116
+		return checksdb.CheckRun{}, err
117
+	}
118
+	committed := false
119
+	defer func() {
120
+		if !committed {
121
+			_ = tx.Rollback(ctx)
122
+		}
123
+	}()
124
+
125
+	conclusionParam := checksdb.NullCheckConclusion{}
126
+	if conclusion != "" {
127
+		conclusionParam = checksdb.NullCheckConclusion{
128
+			CheckConclusion: checksdb.CheckConclusion(conclusion),
129
+			Valid:           true,
130
+		}
131
+	}
132
+
133
+	if err := q.UpdateCheckRun(ctx, tx, checksdb.UpdateCheckRunParams{
134
+		ID:          p.RunID,
135
+		Status:      checksdb.CheckStatus(status),
136
+		Conclusion:  conclusionParam,
137
+		StartedAt:   startedAt,
138
+		CompletedAt: completedAt,
139
+		DetailsUrl:  detailsURL,
140
+		Output:      outputBytes,
141
+	}); err != nil {
142
+		return checksdb.CheckRun{}, fmt.Errorf("update run: %w", err)
143
+	}
144
+	if err := rollupSuiteInTx(ctx, tx, cur.SuiteID); err != nil {
145
+		return checksdb.CheckRun{}, err
146
+	}
147
+	if err := tx.Commit(ctx); err != nil {
148
+		return checksdb.CheckRun{}, err
149
+	}
150
+	committed = true
151
+
152
+	updated, err := q.GetCheckRun(ctx, deps.Pool, p.RunID)
153
+	if err != nil {
154
+		return checksdb.CheckRun{}, err
155
+	}
156
+	return updated, nil
157
+}