Commit0cdfd89unknown_key
feat(BLOCK-J): J8 commit status API + rollup rendering
feat(BLOCK-J): J8 commit status API + rollup rendering GitHub-parity per-commit (sha, context) status signal. External CI posts via POST /api/v1/repos/:o/:r/statuses/:sha with state in pending/success/failure/error. Upsert semantics keyed on (repo, sha, context). Combined rollup reduces to worst state. Reads: GET /commits/:sha/statuses (list) + /commits/:sha/status (rollup), both softAuth with private-repo visibility. Writes: requireAuth + repo-owner check (accepts session, OAuth bearer, or PAT). Commit detail view now renders a Checks pill row when statuses exist, colour-coded per state and linkable to the external target URL. 18 new tests covering pure helpers + route auth + invalid-sha rejection. Total suite: 767/767 passing. https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
8 files changed+659−10cdfd89382ff3a1d89a49520e6c7fb07aa4e79f6
8 changed files+659−1
ModifiedBUILD_BIBLE.md+6−1View fileUnifiedSplit
@@ -91,6 +91,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
9191| Security advisories / Dependabot alerts | ✅ | J2 — curated 12-entry seed list + minimal semver range matcher cross-referenced against J1 dep rows. `src/lib/advisories.ts` + `src/routes/advisories.tsx` serve `/:owner/:repo/security/advisories` (open) + `/all`, owner-only `POST /scan`, and per-alert dismiss/reopen. `drizzle/0029_security_advisories.sql` adds `security_advisories` + `repo_advisory_alerts`. |
9292| Commit signature verification (Verified badge) | ✅ | J3 — GPG + SSH pubkey registration at `/settings/signing-keys`, `gpgsig` extraction from raw commit objects, OpenPGP packet walker for Issuer Fingerprint, SHA-256 fingerprints for SSHSIG pubkeys, memoised in `commit_verifications`. Green "Verified" badge rendered on commit list + detail when a registered key matches. `src/lib/signatures.ts` + `src/routes/signing-keys.tsx` + `drizzle/0030_signing_keys.sql`. |
9393| Repository rulesets (push policy engine) | ✅ | J6 — named policies group N rules at active/evaluate/disabled enforcement. Pure evaluator `evaluatePush(rulesets, ctx)` → `{allowed, violations}`. Six rule types: commit_message_pattern, branch_name_pattern, tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push. Glob-lite matcher (`*` = non-slash, `**` = anything). Owner-only CRUD at `/:owner/:repo/settings/rulesets`. `src/lib/rulesets.ts` + `src/routes/rulesets.tsx` + `drizzle/0032_repo_rulesets.sql`. |
94| Commit status API (external CI signals) | ✅ | J8 — external systems POST per-commit (sha, context) statuses with state pending/success/failure/error. Combined rollup reduces to worst state. Public list + combined endpoints; write requires owner auth. Rendered on commit detail view as a pill row. `src/lib/commit-statuses.ts` + `src/routes/commit-statuses.ts` + `drizzle/0033_commit_statuses.sql`. |
9495
9596### 2.3 Collaboration
9697| Feature | Status | Notes |
@@ -290,6 +291,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
290291- **J5** — Profile READMEs → ✅ shipped. User profile page at `/:owner` now attempts to render `<user>/<user>/README.md` (GitHub convention) or `<user>/.github/README.md` (org-style fallback) as the hero panel above the repo list. No schema changes — reuses `getReadme` / `renderMarkdown` + `repoExists` from the git layer. Failures are silent; missing repo just hides the panel. 2 smoke tests.
291292- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
292293- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
294- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
293295
294296### BLOCK H — Marketplace
295297- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
@@ -305,7 +307,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
305307- `src/app.tsx` — route composition, middleware order, error handlers
306308- `src/index.ts` — Bun server entry
307309- `src/lib/config.ts` — env getters (late-binding)
308- `src/db/schema.ts` — 94 tables. New tables only via new migration.
310- `src/db/schema.ts` — 95 tables. New tables only via new migration.
309311- `src/db/index.ts` — lazy proxy DB connection
310312- `src/db/migrate.ts` — migration runner
311313- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -338,6 +340,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
338340- `drizzle/0030_signing_keys.sql` (Block J3) — migration, never edited in place. Adds `signing_keys` (unique on `(key_type, fingerprint)`) + `commit_verifications` (unique on `(repository_id, commit_sha)`).
339341- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
340342- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
343- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
341344
342345### 4.2 Git layer (locked)
343346- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -468,6 +471,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
468471- `src/lib/rulesets.ts` (Block J6) — exports `RULE_TYPES` (6 types), pure `globToRegex`, defensive `parseParams`, pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `__internal = { evalRule, globToRegex, parseParams }` for tests. Every rule variant no-ops on malformed params or bad regex rather than throwing.
469472- `src/routes/rulesets.tsx` (Block J6) — owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail + enforcement toggle + add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. All mutations `audit()`-logged via the existing `gate(c)` owner pattern.
470473- `src/lib/close-keywords.ts` (Block J7) — pure parser. Exports `extractClosingRefs(text)` + `extractClosingRefsMulti(sources[])`. Verbs: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved. Case-insensitive, word-boundary-respecting, ignores cross-repo `owner/repo#N` refs, rejects non-positive numbers, returns sorted de-duped list. Tolerates `:` / `-` / whitespace between verb and ref.
474- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
475- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
471476
472477### 4.7 Views (locked contracts)
473478- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0033_commit_statuses.sql+24−0View fileUnifiedSplit
@@ -0,0 +1,24 @@
1-- Block J8 — Commit statuses (GitHub-parity external CI signal).
2--
3-- External systems post per-commit (sha, context) statuses that appear on
4-- commit detail views and fuel future merge-gating. Per (repo, sha, context)
5-- upsert semantics — a repost with the same context replaces the prior row.
6
7CREATE TABLE IF NOT EXISTS "commit_statuses" (
8 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
10 "commit_sha" text NOT NULL,
11 "state" text NOT NULL, -- 'pending' | 'success' | 'failure' | 'error'
12 "context" text NOT NULL DEFAULT 'default',
13 "description" text,
14 "target_url" text,
15 "creator_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
16 "created_at" timestamp NOT NULL DEFAULT now(),
17 "updated_at" timestamp NOT NULL DEFAULT now()
18);
19
20CREATE UNIQUE INDEX IF NOT EXISTS "commit_statuses_repo_sha_context_unique"
21 ON "commit_statuses" ("repository_id", "commit_sha", "context");
22
23CREATE INDEX IF NOT EXISTS "commit_statuses_repo_sha_idx"
24 ON "commit_statuses" ("repository_id", "commit_sha");
Addedsrc/__tests__/commit-statuses.test.ts+159−0View fileUnifiedSplit
@@ -0,0 +1,159 @@
1/**
2 * Block J8 — Commit status API tests. Pure helpers + route-auth smokes.
3 * DB-backed CRUD is covered in integration.
4 */
5
6import { describe, it, expect } from "bun:test";
7import app from "../app";
8import {
9 STATUS_STATES,
10 isValidSha,
11 isValidState,
12 reduceCombined,
13 sanitiseContext,
14 __internal,
15} from "../lib/commit-statuses";
16
17describe("commit-statuses — isValidSha", () => {
18 it("accepts 4 to 40 hex chars", () => {
19 expect(isValidSha("abcd")).toBe(true);
20 expect(isValidSha("a".repeat(40))).toBe(true);
21 expect(isValidSha("DEADBEEF")).toBe(true);
22 });
23
24 it("rejects too short / too long / bad chars / empty / null", () => {
25 expect(isValidSha("abc")).toBe(false);
26 expect(isValidSha("a".repeat(41))).toBe(false);
27 expect(isValidSha("xyz1")).toBe(false);
28 expect(isValidSha("")).toBe(false);
29 expect(isValidSha(null)).toBe(false);
30 expect(isValidSha(undefined)).toBe(false);
31 });
32});
33
34describe("commit-statuses — isValidState", () => {
35 it("accepts the four canonical states", () => {
36 for (const s of STATUS_STATES) expect(isValidState(s)).toBe(true);
37 });
38
39 it("rejects anything else", () => {
40 expect(isValidState("")).toBe(false);
41 expect(isValidState("passed")).toBe(false);
42 expect(isValidState("ok")).toBe(false);
43 expect(isValidState(null)).toBe(false);
44 expect(isValidState(42)).toBe(false);
45 expect(isValidState(undefined)).toBe(false);
46 });
47});
48
49describe("commit-statuses — sanitiseContext", () => {
50 it("defaults to 'default' on empty / nullish", () => {
51 expect(sanitiseContext("")).toBe("default");
52 expect(sanitiseContext(null)).toBe("default");
53 expect(sanitiseContext(undefined)).toBe("default");
54 expect(sanitiseContext(" ")).toBe("default");
55 });
56
57 it("trims and caps length", () => {
58 expect(sanitiseContext(" ci/build ")).toBe("ci/build");
59 const long = "x".repeat(500);
60 expect(sanitiseContext(long).length).toBe(__internal.CONTEXT_MAX);
61 });
62});
63
64describe("commit-statuses — reduceCombined", () => {
65 it("empty list rolls up as success (no blockers)", () => {
66 expect(reduceCombined([])).toBe("success");
67 });
68
69 it("any failure dominates", () => {
70 expect(reduceCombined(["success", "failure"])).toBe("failure");
71 expect(reduceCombined(["pending", "failure"])).toBe("failure");
72 });
73
74 it("any error dominates (bucketed as failure)", () => {
75 expect(reduceCombined(["success", "error"])).toBe("failure");
76 expect(reduceCombined(["pending", "error"])).toBe("failure");
77 });
78
79 it("no failure + any pending → pending", () => {
80 expect(reduceCombined(["pending"])).toBe("pending");
81 expect(reduceCombined(["success", "pending", "success"])).toBe("pending");
82 });
83
84 it("all success → success", () => {
85 expect(reduceCombined(["success", "success", "success"])).toBe("success");
86 });
87});
88
89describe("commit-statuses — __internal.clamp", () => {
90 it("returns null for empty / nullish", () => {
91 expect(__internal.clamp("", 10)).toBeNull();
92 expect(__internal.clamp(null, 10)).toBeNull();
93 expect(__internal.clamp(undefined, 10)).toBeNull();
94 });
95
96 it("caps at max", () => {
97 expect(__internal.clamp("abcdef", 3)).toBe("abc");
98 expect(__internal.clamp("ok", 10)).toBe("ok");
99 });
100});
101
102// ---------------------------------------------------------------------------
103// Route auth — these run without a DB so we only assert unauthenticated
104// behaviour. Authenticated paths (owner check, actual upsert) belong in
105// integration tests against a live DB.
106// ---------------------------------------------------------------------------
107
108describe("commit-statuses — route auth", () => {
109 it("POST without auth → 302 /login redirect", async () => {
110 const res = await app.request(
111 "/api/v1/repos/alice/repo/statuses/abc1234",
112 {
113 method: "POST",
114 headers: { "content-type": "application/json" },
115 body: JSON.stringify({ state: "success" }),
116 }
117 );
118 expect(res.status).toBe(302);
119 expect(res.headers.get("location") || "").toContain("/login");
120 });
121
122 it("POST with invalid bearer token → 401 JSON", async () => {
123 const res = await app.request(
124 "/api/v1/repos/alice/repo/statuses/abc1234",
125 {
126 method: "POST",
127 headers: {
128 "content-type": "application/json",
129 authorization: "Bearer glc_not_a_real_token",
130 },
131 body: JSON.stringify({ state: "success" }),
132 }
133 );
134 expect(res.status).toBe(401);
135 });
136
137 it("GET list returns JSON (200 / 404 / 500 depending on env)", async () => {
138 const res = await app.request(
139 "/api/v1/repos/alice/repo/commits/abc1234/statuses"
140 );
141 expect([200, 404, 500]).toContain(res.status);
142 });
143
144 it("GET combined status returns JSON", async () => {
145 const res = await app.request(
146 "/api/v1/repos/alice/repo/commits/abc1234/status"
147 );
148 expect([200, 404, 500]).toContain(res.status);
149 });
150
151 it("GET with obviously invalid sha → 400", async () => {
152 const res = await app.request(
153 "/api/v1/repos/alice/repo/commits/NOT_HEX/status"
154 );
155 expect(res.status).toBe(400);
156 const body = await res.json();
157 expect(body.error).toContain("sha");
158 });
159});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -74,6 +74,7 @@ import advisoriesRoutes from "./routes/advisories";
7474import signingKeysRoutes from "./routes/signing-keys";
7575import followsRoutes from "./routes/follows";
7676import rulesetsRoutes from "./routes/rulesets";
77import commitStatusesRoutes from "./routes/commit-statuses";
7778import webRoutes from "./routes/web";
7879
7980const app = new Hono();
@@ -258,6 +259,9 @@ app.route("/", followsRoutes);
258259// Repository rulesets — /:owner/:repo/settings/rulesets (Block J6)
259260app.route("/", rulesetsRoutes);
260261
262// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
263app.route("/", commitStatusesRoutes);
264
261265// Insights + milestones
262266app.route("/", insightsRoutes);
263267
Modifiedsrc/db/schema.ts+43−0View fileUnifiedSplit
@@ -2316,3 +2316,46 @@ export const rulesetRules = pgTable(
23162316);
23172317
23182318export type RulesetRule = typeof rulesetRules.$inferSelect;
2319
2320// ---------------------------------------------------------------------------
2321// Block J8 — Commit statuses.
2322// ---------------------------------------------------------------------------
2323
2324/**
2325 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2326 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2327 * pending | success | failure | error. Combined rollup logic lives in
2328 * `src/lib/commit-statuses.ts`.
2329 */
2330export const commitStatuses = pgTable(
2331 "commit_statuses",
2332 {
2333 id: uuid("id").primaryKey().defaultRandom(),
2334 repositoryId: uuid("repository_id")
2335 .notNull()
2336 .references(() => repositories.id, { onDelete: "cascade" }),
2337 commitSha: text("commit_sha").notNull(),
2338 state: text("state").notNull(),
2339 context: text("context").default("default").notNull(),
2340 description: text("description"),
2341 targetUrl: text("target_url"),
2342 creatorId: uuid("creator_id").references(() => users.id, {
2343 onDelete: "set null",
2344 }),
2345 createdAt: timestamp("created_at").defaultNow().notNull(),
2346 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2347 },
2348 (table) => [
2349 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2350 table.repositoryId,
2351 table.commitSha,
2352 table.context
2353 ),
2354 index("commit_statuses_repo_sha_idx").on(
2355 table.repositoryId,
2356 table.commitSha
2357 ),
2358 ]
2359);
2360
2361export type CommitStatus = typeof commitStatuses.$inferSelect;
Addedsrc/lib/commit-statuses.ts+196−0View fileUnifiedSplit
@@ -0,0 +1,196 @@
1/**
2 * Block J8 — Commit statuses (GitHub-parity external CI signal).
3 *
4 * External systems POST per-commit (sha, context) statuses that appear on
5 * the commit detail view and combined-status rollup endpoints. Upsert
6 * semantics: a post with the same (repo, sha, context) replaces the prior
7 * row. State vocabulary: pending | success | failure | error.
8 */
9
10import { and, desc, eq } from "drizzle-orm";
11import { db } from "../db";
12import { commitStatuses, type CommitStatus } from "../db/schema";
13
14export type StatusState = "pending" | "success" | "failure" | "error";
15
16export const STATUS_STATES: StatusState[] = [
17 "pending",
18 "success",
19 "failure",
20 "error",
21];
22
23const CONTEXT_MAX = 120;
24const DESCRIPTION_MAX = 1000;
25const URL_MAX = 2048;
26
27export interface SetStatusInput {
28 repositoryId: string;
29 commitSha: string;
30 state: StatusState;
31 context?: string | null;
32 description?: string | null;
33 targetUrl?: string | null;
34 creatorId?: string | null;
35}
36
37/** Git short-sha / full-sha sanity check. */
38export function isValidSha(sha: string | null | undefined): boolean {
39 if (!sha) return false;
40 if (sha.length < 4 || sha.length > 40) return false;
41 return /^[a-f0-9]+$/i.test(sha);
42}
43
44export function isValidState(s: unknown): s is StatusState {
45 return typeof s === "string" && STATUS_STATES.includes(s as StatusState);
46}
47
48export function sanitiseContext(ctx: string | null | undefined): string {
49 const raw = (ctx || "default").trim();
50 if (!raw) return "default";
51 return raw.slice(0, CONTEXT_MAX);
52}
53
54function clamp(
55 s: string | null | undefined,
56 max: number
57): string | null {
58 if (!s) return null;
59 const t = s.toString();
60 if (!t.length) return null;
61 return t.slice(0, max);
62}
63
64/**
65 * Upsert a commit status. Returns the final row. Throws only on bad state
66 * input — callers normalise via `isValidState` first.
67 */
68export async function setStatus(
69 input: SetStatusInput
70): Promise<CommitStatus | null> {
71 if (!isValidState(input.state)) return null;
72 if (!isValidSha(input.commitSha)) return null;
73 const ctx = sanitiseContext(input.context);
74 const sha = input.commitSha.toLowerCase();
75 const description = clamp(input.description, DESCRIPTION_MAX);
76 const targetUrl = clamp(input.targetUrl, URL_MAX);
77 // Delete-then-insert keeps the table simple without relying on ON CONFLICT.
78 await db
79 .delete(commitStatuses)
80 .where(
81 and(
82 eq(commitStatuses.repositoryId, input.repositoryId),
83 eq(commitStatuses.commitSha, sha),
84 eq(commitStatuses.context, ctx)
85 )
86 );
87 const [row] = await db
88 .insert(commitStatuses)
89 .values({
90 repositoryId: input.repositoryId,
91 commitSha: sha,
92 state: input.state,
93 context: ctx,
94 description,
95 targetUrl,
96 creatorId: input.creatorId || null,
97 })
98 .returning();
99 return row || null;
100}
101
102/** List statuses for a commit, newest first. */
103export async function listStatuses(
104 repositoryId: string,
105 commitSha: string
106): Promise<CommitStatus[]> {
107 if (!isValidSha(commitSha)) return [];
108 return db
109 .select()
110 .from(commitStatuses)
111 .where(
112 and(
113 eq(commitStatuses.repositoryId, repositoryId),
114 eq(commitStatuses.commitSha, commitSha.toLowerCase())
115 )
116 )
117 .orderBy(desc(commitStatuses.updatedAt));
118}
119
120export interface CombinedStatus {
121 state: StatusState | "success";
122 total: number;
123 counts: Record<StatusState, number>;
124 contexts: Array<{
125 context: string;
126 state: StatusState;
127 description: string | null;
128 targetUrl: string | null;
129 updatedAt: Date;
130 }>;
131}
132
133/**
134 * Reduce a list of statuses to a single roll-up state.
135 * any failure/error → "failure"
136 * any pending → "pending"
137 * all success → "success"
138 * empty list → "success" (no signal means no blocker)
139 *
140 * Pure — exposed for tests.
141 */
142export function reduceCombined(states: StatusState[]): StatusState {
143 if (!states.length) return "success" as StatusState;
144 if (states.some((s) => s === "failure" || s === "error")) return "failure";
145 if (states.some((s) => s === "pending")) return "pending";
146 return "success";
147}
148
149/**
150 * GitHub-style combined status. Groups statuses by context (latest per
151 * context wins — which the upsert guarantees anyway) and reduces to a
152 * single state.
153 */
154export async function combinedStatus(
155 repositoryId: string,
156 commitSha: string
157): Promise<CombinedStatus> {
158 const rows = await listStatuses(repositoryId, commitSha);
159 const byContext = new Map<string, CommitStatus>();
160 for (const r of rows) {
161 const prev = byContext.get(r.context);
162 if (!prev || prev.updatedAt < r.updatedAt) byContext.set(r.context, r);
163 }
164 const latest = [...byContext.values()];
165 const counts: Record<StatusState, number> = {
166 pending: 0,
167 success: 0,
168 failure: 0,
169 error: 0,
170 };
171 for (const r of latest) {
172 if (isValidState(r.state)) counts[r.state]++;
173 }
174 const state = reduceCombined(latest.map((r) => r.state as StatusState));
175 return {
176 state,
177 total: latest.length,
178 counts,
179 contexts: latest
180 .sort((a, b) => a.context.localeCompare(b.context))
181 .map((r) => ({
182 context: r.context,
183 state: r.state as StatusState,
184 description: r.description,
185 targetUrl: r.targetUrl,
186 updatedAt: r.updatedAt,
187 })),
188 };
189}
190
191export const __internal = {
192 CONTEXT_MAX,
193 DESCRIPTION_MAX,
194 URL_MAX,
195 clamp,
196};
Addedsrc/routes/commit-statuses.ts+149−0View fileUnifiedSplit
@@ -0,0 +1,149 @@
1/**
2 * Block J8 — Commit status API (GitHub-parity).
3 *
4 * External CI / automation systems POST statuses against a (repo, sha, context)
5 * triple. Reads are public for public repos (softAuth visibility check) and
6 * writes require repo-owner auth (session / OAuth / PAT accepted by
7 * requireAuth).
8 *
9 * POST /api/v1/repos/:owner/:repo/statuses/:sha
10 * body: { state, context?, description?, target_url? }
11 * 200: { ok: true, status }
12 * 400: invalid state / sha
13 * 401/403: auth / permission
14 *
15 * GET /api/v1/repos/:owner/:repo/commits/:sha/statuses
16 * 200: { total, statuses: [...] }
17 *
18 * GET /api/v1/repos/:owner/:repo/commits/:sha/status
19 * 200: { state, total, counts, contexts }
20 */
21
22import { Hono } from "hono";
23import { and, eq } from "drizzle-orm";
24import { db } from "../db";
25import { repositories, users } from "../db/schema";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 combinedStatus,
30 isValidSha,
31 isValidState,
32 listStatuses,
33 setStatus,
34} from "../lib/commit-statuses";
35
36const statuses = new Hono<AuthEnv>();
37
38async function resolveRepo(ownerName: string, repoName: string) {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
50 )
51 .limit(1);
52 if (!repo) return null;
53 return { owner, repo };
54}
55
56// ---------------------------------------------------------------------------
57// POST status
58// ---------------------------------------------------------------------------
59statuses.post(
60 "/api/v1/repos/:owner/:repo/statuses/:sha",
61 softAuth,
62 requireAuth,
63 async (c) => {
64 const { owner: ownerName, repo: repoName, sha } = c.req.param();
65 const user = c.get("user")!;
66 if (!isValidSha(sha)) {
67 return c.json({ error: "Invalid sha" }, 400);
68 }
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.json({ error: "Repository not found" }, 404);
71 if (resolved.owner.id !== user.id) {
72 return c.json({ error: "Forbidden" }, 403);
73 }
74
75 let body: any = {};
76 try {
77 body = await c.req.json();
78 } catch {
79 body = {};
80 }
81
82 const state = body.state;
83 if (!isValidState(state)) {
84 return c.json(
85 {
86 error:
87 "Invalid state; must be one of pending, success, failure, error",
88 },
89 400
90 );
91 }
92
93 const row = await setStatus({
94 repositoryId: resolved.repo.id,
95 commitSha: sha,
96 state,
97 context: body.context ?? body.Context ?? "default",
98 description: body.description ?? null,
99 targetUrl: body.target_url ?? body.targetUrl ?? null,
100 creatorId: user.id,
101 });
102
103 if (!row) return c.json({ error: "Could not save status" }, 500);
104
105 return c.json({ ok: true, status: row });
106 }
107);
108
109// ---------------------------------------------------------------------------
110// GET statuses list
111// ---------------------------------------------------------------------------
112statuses.get(
113 "/api/v1/repos/:owner/:repo/commits/:sha/statuses",
114 softAuth,
115 async (c) => {
116 const { owner: ownerName, repo: repoName, sha } = c.req.param();
117 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
118 const resolved = await resolveRepo(ownerName, repoName);
119 if (!resolved) return c.json({ error: "Repository not found" }, 404);
120 const user = c.get("user");
121 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
122 return c.json({ error: "Forbidden" }, 403);
123 }
124 const rows = await listStatuses(resolved.repo.id, sha);
125 return c.json({ total: rows.length, statuses: rows });
126 }
127);
128
129// ---------------------------------------------------------------------------
130// GET combined status
131// ---------------------------------------------------------------------------
132statuses.get(
133 "/api/v1/repos/:owner/:repo/commits/:sha/status",
134 softAuth,
135 async (c) => {
136 const { owner: ownerName, repo: repoName, sha } = c.req.param();
137 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
138 const resolved = await resolveRepo(ownerName, repoName);
139 if (!resolved) return c.json({ error: "Repository not found" }, 404);
140 const user = c.get("user");
141 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
142 return c.json({ error: "Forbidden" }, 403);
143 }
144 const combined = await combinedStatus(resolved.repo.id, sha);
145 return c.json(combined);
146 }
147);
148
149export default statuses;
Modifiedsrc/routes/web.tsx+78−0View fileUnifiedSplit
@@ -855,6 +855,19 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
855855 let verification:
856856 | { verified: boolean; reason: string; signatureType: string | null }
857857 | null = null;
858 // Block J8 — external CI commit statuses rollup.
859 let statusCombined:
860 | {
861 state: "pending" | "success" | "failure";
862 total: number;
863 contexts: Array<{
864 context: string;
865 state: string;
866 description: string | null;
867 targetUrl: string | null;
868 }>;
869 }
870 | null = null;
858871 try {
859872 const [ownerRow] = await db
860873 .select()
@@ -880,6 +893,24 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
880893 reason: v.reason,
881894 signatureType: v.signatureType,
882895 };
896 try {
897 const { combinedStatus } = await import("../lib/commit-statuses");
898 const combined = await combinedStatus(repoRow.id, commit.sha);
899 if (combined.total > 0) {
900 statusCombined = {
901 state: combined.state as any,
902 total: combined.total,
903 contexts: combined.contexts.map((c) => ({
904 context: c.context,
905 state: c.state,
906 description: c.description,
907 targetUrl: c.targetUrl,
908 })),
909 };
910 }
911 } catch {
912 statusCombined = null;
913 }
883914 }
884915 }
885916 } catch {
@@ -940,6 +971,53 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
940971 </span>
941972 )}
942973 </div>
974 {statusCombined && (
975 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
976 <strong style="color: var(--text)">Checks</strong>
977 <span style="margin-left: 8px; color: var(--text-muted)">
978 {statusCombined.total} total —{" "}
979 <span
980 style={`color:${
981 statusCombined.state === "success"
982 ? "var(--green,#2ea043)"
983 : statusCombined.state === "failure"
984 ? "var(--red,#da3633)"
985 : "var(--yellow,#d29922)"
986 }`}
987 >
988 {statusCombined.state}
989 </span>
990 </span>
991 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
992 {statusCombined.contexts.map((cx) => (
993 <span
994 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
995 cx.state === "success"
996 ? "var(--green,#2ea043)"
997 : cx.state === "pending"
998 ? "var(--yellow,#d29922)"
999 : "var(--red,#da3633)"
1000 }`}
1001 title={cx.description || cx.context}
1002 >
1003 {cx.targetUrl ? (
1004 <a
1005 href={cx.targetUrl}
1006 style="color: inherit; text-decoration: none"
1007 rel="noopener"
1008 >
1009 {cx.context}: {cx.state}
1010 </a>
1011 ) : (
1012 <>
1013 {cx.context}: {cx.state}
1014 </>
1015 )}
1016 </span>
1017 ))}
1018 </div>
1019 </div>
1020 )}
9431021 </div>
9441022 <DiffView raw={raw} files={files} />
9451023 </Layout>
9461024