Skip to content

Commit ae59938

Browse files
committed
feat(miner): add read-only CI check-run poller
Add the miner CI check-run poller package surface with typed entrypoints and unit coverage. Handle paginated GitHub check-run responses, terminal stale conclusions, malformed payloads, and trusted GitHub API origin validation so authenticated requests cannot exfiltrate tokens to arbitrary hosts.
1 parent 84b9a50 commit ae59938

4 files changed

Lines changed: 521 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral";
2+
3+
export type NormalizedCheckRun = {
4+
name: string;
5+
status: string;
6+
conclusion: CheckRunConclusion;
7+
detailsUrl: string | null;
8+
startedAt: string | null;
9+
completedAt: string | null;
10+
};
11+
12+
export type PollCheckRunsResult = {
13+
conclusion: CheckRunConclusion;
14+
checks: NormalizedCheckRun[];
15+
headSha: string;
16+
attempts: number;
17+
};
18+
19+
export type PollCheckRunsOptions = {
20+
apiBaseUrl?: string;
21+
fetchFn?: typeof fetch;
22+
githubToken?: string;
23+
maxAttempts?: number;
24+
minIntervalMs?: number;
25+
maxIntervalMs?: number;
26+
sleepFn?: (delayMs: number) => Promise<unknown>;
27+
};
28+
29+
export function pollCheckRuns(
30+
repoFullName: string,
31+
prNumber: number,
32+
options?: PollCheckRunsOptions,
33+
): Promise<PollCheckRunsResult>;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
const defaultApiBaseUrl = "https://api.github.com";
2+
const defaultMinIntervalMs = 60_000;
3+
const defaultMaxIntervalMs = 5 * 60_000;
4+
const defaultMaxAttempts = 1;
5+
const githubApiVersion = "2022-11-28";
6+
7+
function normalizeApiBaseUrl(value) {
8+
if (value === undefined) return defaultApiBaseUrl;
9+
if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl;
10+
let parsed;
11+
try {
12+
parsed = new URL(value.trim());
13+
} catch {
14+
throw new Error("invalid_api_base_url");
15+
}
16+
if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") {
17+
throw new Error("invalid_api_base_url");
18+
}
19+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
20+
parsed.search = "";
21+
parsed.hash = "";
22+
return parsed.toString().replace(/\/+$/, "");
23+
}
24+
25+
function normalizePositiveInt(value, fallback, min, max) {
26+
if (!Number.isFinite(value)) return fallback;
27+
return Math.min(max, Math.max(min, Math.floor(value)));
28+
}
29+
30+
function normalizeOptions(options = {}) {
31+
return {
32+
apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl),
33+
fetchFn: options.fetchFn ?? fetch,
34+
githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "",
35+
maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20),
36+
minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000),
37+
maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000),
38+
sleepFn:
39+
options.sleepFn ??
40+
((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))),
41+
};
42+
}
43+
44+
function parseRepoFullName(repoFullName) {
45+
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
46+
const [owner, repo, extra] = repoFullName.split("/");
47+
if (!owner?.trim() || !repo?.trim() || extra !== undefined) {
48+
throw new Error("invalid_repo_full_name");
49+
}
50+
return { owner: owner.trim(), repo: repo.trim() };
51+
}
52+
53+
function normalizePullNumber(value) {
54+
if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number");
55+
return value;
56+
}
57+
58+
function githubHeaders(githubToken) {
59+
const headers = {
60+
accept: "application/vnd.github+json",
61+
"user-agent": "gittensory-miner",
62+
"x-github-api-version": githubApiVersion,
63+
};
64+
if (githubToken) headers.authorization = `Bearer ${githubToken}`;
65+
return headers;
66+
}
67+
68+
function repoPath(target, suffix) {
69+
return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`;
70+
}
71+
72+
function apiUrl(apiBaseUrl, path, query = "") {
73+
return `${apiBaseUrl}${path}${query}`;
74+
}
75+
76+
function githubError(response, payload) {
77+
const code = `github_${response.status}`;
78+
const githubMessage =
79+
typeof payload?.message === "string" && payload.message.trim() ? payload.message : null;
80+
const message = githubMessage ? `${code}: ${githubMessage}` : code;
81+
return Object.assign(new Error(message), { code, githubMessage });
82+
}
83+
84+
async function githubGetJsonResponse(url, options) {
85+
const response = await options.fetchFn(url, {
86+
method: "GET",
87+
headers: githubHeaders(options.githubToken),
88+
});
89+
const payload = await response.json().catch(() => null);
90+
if (!response.ok) {
91+
throw githubError(response, payload);
92+
}
93+
return { payload, response };
94+
}
95+
96+
async function githubGetJson(url, options) {
97+
const { payload } = await githubGetJsonResponse(url, options);
98+
return payload;
99+
}
100+
101+
function hasNextLink(response) {
102+
return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? "");
103+
}
104+
105+
function payloadTotalCount(payload) {
106+
const totalCount = Number(payload?.total_count);
107+
return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null;
108+
}
109+
110+
function normalizeConclusion(checkRun) {
111+
if (!checkRun || typeof checkRun !== "object") return "pending";
112+
if (checkRun.status !== "completed") return "pending";
113+
switch (checkRun.conclusion) {
114+
case "success":
115+
case "skipped":
116+
return "success";
117+
case "neutral":
118+
return "neutral";
119+
case "failure":
120+
case "cancelled":
121+
case "timed_out":
122+
case "action_required":
123+
case "stale":
124+
case "startup_failure":
125+
return "failure";
126+
default:
127+
return "pending";
128+
}
129+
}
130+
131+
function normalizeCheckRun(checkRun) {
132+
return {
133+
name: typeof checkRun?.name === "string" ? checkRun.name : "",
134+
status: typeof checkRun?.status === "string" ? checkRun.status : "unknown",
135+
conclusion: normalizeConclusion(checkRun),
136+
detailsUrl: typeof checkRun?.details_url === "string" ? checkRun.details_url : null,
137+
startedAt: typeof checkRun?.started_at === "string" ? checkRun.started_at : null,
138+
completedAt: typeof checkRun?.completed_at === "string" ? checkRun.completed_at : null,
139+
};
140+
}
141+
142+
function aggregateConclusion(checks) {
143+
if (checks.length === 0) return "pending";
144+
if (checks.some((check) => check.conclusion === "failure")) return "failure";
145+
if (checks.some((check) => check.conclusion === "pending")) return "pending";
146+
if (checks.every((check) => check.conclusion === "success")) return "success";
147+
return "neutral";
148+
}
149+
150+
function backoffDelayMs(attemptIndex, options) {
151+
const exponent = Math.min(10, Math.max(0, attemptIndex));
152+
return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent);
153+
}
154+
155+
async function fetchHeadSha(target, prNumber, options) {
156+
const payload = await githubGetJson(
157+
apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)),
158+
options,
159+
);
160+
const headSha = payload?.head?.sha;
161+
if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing");
162+
return headSha;
163+
}
164+
165+
async function fetchCheckRuns(target, headSha, options) {
166+
const checks = [];
167+
let page = 1;
168+
let expectedTotalCount = null;
169+
while (true) {
170+
const { payload, response } = await githubGetJsonResponse(
171+
apiUrl(
172+
options.apiBaseUrl,
173+
repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`),
174+
`?per_page=100&page=${page}`,
175+
),
176+
options,
177+
);
178+
if (!Array.isArray(payload?.check_runs)) {
179+
throw new Error("github_check_runs_malformed");
180+
}
181+
const pageChecks = payload.check_runs.map(normalizeCheckRun);
182+
checks.push(...pageChecks);
183+
expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount;
184+
if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) {
185+
return checks;
186+
}
187+
if (pageChecks.length === 0) {
188+
throw new Error("github_check_runs_pagination_incomplete");
189+
}
190+
page += 1;
191+
}
192+
}
193+
194+
export async function pollCheckRuns(repoFullName, prNumber, options = {}) {
195+
const target = parseRepoFullName(repoFullName);
196+
const normalizedPrNumber = normalizePullNumber(prNumber);
197+
const normalizedOptions = normalizeOptions(options);
198+
const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions);
199+
200+
let latest = { conclusion: "pending", checks: [], headSha, attempts: 0 };
201+
for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) {
202+
const checks = await fetchCheckRuns(target, headSha, normalizedOptions);
203+
latest = {
204+
conclusion: aggregateConclusion(checks),
205+
checks,
206+
headSha,
207+
attempts: attempt + 1,
208+
};
209+
if (latest.conclusion !== "pending" || attempt === normalizedOptions.maxAttempts - 1) {
210+
return latest;
211+
}
212+
await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions));
213+
}
214+
215+
return latest;
216+
}

packages/gittensory-miner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"lib"
3232
],
3333
"scripts": {
34-
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js"
34+
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js"
3535
},
3636
"dependencies": {
3737
"@jsonbored/gittensory-engine": "0.1.0"

0 commit comments

Comments
 (0)