Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

commit-statuses.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

commit-statuses.tsBlame196 lines · 1 contributor
0cdfd89Claude1/**
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};