719 lines
24 KiB
JavaScript
719 lines
24 KiB
JavaScript
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { createGhApiRequest } from "./github-api.mjs";
|
|
|
|
const OUTPUT_DIRECTORY = join(".cache", "ci-pr-health");
|
|
const PAGE_SIZE = 100;
|
|
const MAX_PULL_REQUEST_PAGES = 3;
|
|
const MAX_WORKFLOW_RUN_PAGES = 5;
|
|
const MAX_PULL_REQUEST_DETAILS = 40;
|
|
const MAX_API_REQUESTS =
|
|
MAX_PULL_REQUEST_PAGES + MAX_WORKFLOW_RUN_PAGES + MAX_PULL_REQUEST_DETAILS;
|
|
const LOOKBACK_DAYS = 14;
|
|
const MAX_LOOKBACK_DAYS = 30;
|
|
const MAX_TOTAL_TIME_MS = 60_000;
|
|
const SHA_PATTERN = /^[0-9a-f]{40}$/iu;
|
|
const COMPLETED_CATEGORIES = new Map([
|
|
["failure", "completed_failure"],
|
|
["timed_out", "completed_failure"],
|
|
["cancelled", "completed_canceled"],
|
|
["success", "completed_success"],
|
|
]);
|
|
|
|
/** Read workflow/PR state and conservatively distinguish absent CI from unknown. */
|
|
export async function collectPrCiHealth({
|
|
repository,
|
|
repositoryId,
|
|
defaultBranch,
|
|
lookbackDays = LOOKBACK_DAYS,
|
|
now = Date.now(),
|
|
apiRequest = createGhApiRequest(),
|
|
}) {
|
|
const identity = validateInputs({
|
|
repository,
|
|
repositoryId,
|
|
defaultBranch,
|
|
lookbackDays,
|
|
now,
|
|
});
|
|
const observedAt = new Date(now).toISOString();
|
|
const cutoff = now - identity.lookbackDays * 24 * 60 * 60 * 1_000;
|
|
const snapshot = await collectApiSnapshot({ apiRequest, identity, cutoff });
|
|
const classified = classifyPullRequests(snapshot);
|
|
const { pulls, runs, budget } = snapshot;
|
|
const errors = [pulls.error, runs.error, ...snapshot.detailsErrors].filter(
|
|
Boolean,
|
|
);
|
|
return {
|
|
schemaVersion: 1,
|
|
observedAt,
|
|
repository: identity.repository,
|
|
repositoryId: identity.repositoryId,
|
|
defaultBranch: identity.defaultBranch,
|
|
lookbackDays: identity.lookbackDays,
|
|
scan: {
|
|
status:
|
|
errors.length === 0 && pulls.complete && runs.complete
|
|
? "complete"
|
|
: "partial",
|
|
cutoff: new Date(cutoff).toISOString(),
|
|
pullRequestPages: pulls.pages,
|
|
workflowRunPages: runs.pages,
|
|
pullRequestInventoryComplete: pulls.complete,
|
|
workflowRunInventoryComplete: runs.complete,
|
|
pullRequestDetailsFetched: snapshot.detailsFetched,
|
|
pullRequestDetailsLimit: MAX_PULL_REQUEST_DETAILS,
|
|
apiRequests: budget.requests,
|
|
apiRequestLimit: MAX_API_REQUESTS,
|
|
errors,
|
|
},
|
|
counts: countCategories(classified),
|
|
pullRequests: classified,
|
|
};
|
|
}
|
|
|
|
async function collectApiSnapshot({ apiRequest, identity, cutoff }) {
|
|
const budget = {
|
|
requests: 0,
|
|
startedAt: Date.now(),
|
|
limitMs: MAX_TOTAL_TIME_MS,
|
|
};
|
|
const requestGet = async (endpoint) => {
|
|
if (budget.requests >= MAX_API_REQUESTS)
|
|
throw new Error("lookup_request_budget_exhausted");
|
|
if (Date.now() - budget.startedAt >= budget.limitMs)
|
|
throw new Error("lookup_time_budget_exhausted");
|
|
budget.requests += 1;
|
|
return await apiRequest("GET", endpoint, budget);
|
|
};
|
|
const pulls = await listOpenPullRequests({
|
|
requestGet,
|
|
identity,
|
|
});
|
|
const candidates = pulls.items.filter(
|
|
(pull) =>
|
|
!pull.draft &&
|
|
Number.isFinite(Date.parse(pull.createdAt ?? "")) &&
|
|
Date.parse(pull.createdAt ?? "") >= cutoff &&
|
|
pull.base.ref === identity.defaultBranch &&
|
|
(identity.repositoryId === null ||
|
|
pull.base.repositoryId === identity.repositoryId),
|
|
);
|
|
const runs = await listRecentWorkflowRuns({
|
|
requestGet,
|
|
identity,
|
|
cutoff,
|
|
});
|
|
|
|
const detailResult = await resolveMergeability({
|
|
candidates,
|
|
requestGet,
|
|
identity,
|
|
});
|
|
return {
|
|
budget,
|
|
pulls,
|
|
runs,
|
|
candidates,
|
|
detailsFetched: detailResult.fetched,
|
|
detailsErrors: detailResult.errors,
|
|
};
|
|
}
|
|
|
|
function classifyPullRequests(snapshot) {
|
|
const { candidates, pulls, runs } = snapshot;
|
|
const identityCounts = countHeadIdentities(pulls.items);
|
|
return candidates.map((pull) => {
|
|
const relevant = [];
|
|
const ambiguousRuns = [];
|
|
for (const run of runs.items) {
|
|
const match = matchRunToPullRequest({
|
|
run,
|
|
pull,
|
|
identityCounts,
|
|
inventoryComplete: pulls.complete,
|
|
});
|
|
if (match.kind === "match") relevant.push({ run, basis: match.basis });
|
|
if (match.kind === "ambiguous")
|
|
ambiguousRuns.push({ run, basis: "ambiguous_head_identity" });
|
|
}
|
|
return {
|
|
...pull,
|
|
ci: classifyPrCi({
|
|
pull,
|
|
runs: relevant,
|
|
runsComplete: runs.complete,
|
|
pullRequestInventoryComplete: pulls.complete,
|
|
ambiguousRuns,
|
|
}),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function resolveMergeability({ candidates, requestGet, identity }) {
|
|
let detailsFetched = 0;
|
|
const errors = new Set();
|
|
for (const pull of candidates) {
|
|
if (pull.mergeable !== undefined) continue;
|
|
if (detailsFetched >= MAX_PULL_REQUEST_DETAILS) {
|
|
errors.add("pull_request_mergeability_lookup_budget_exhausted");
|
|
continue;
|
|
}
|
|
const result = await getPullRequestDetails({ requestGet, identity, pull });
|
|
detailsFetched += 1;
|
|
if (result.error !== null) {
|
|
errors.add(result.error);
|
|
continue;
|
|
}
|
|
const { detail } = result;
|
|
const identityChanged =
|
|
detail.headSha !== pull.head.sha ||
|
|
detail.headRef !== pull.head.ref ||
|
|
detail.headRepositoryId !== pull.head.repositoryId ||
|
|
detail.baseRef !== pull.base.ref ||
|
|
detail.baseRepositoryId !== pull.base.repositoryId;
|
|
if (identityChanged) {
|
|
pull.identityValid = false;
|
|
pull.identityChangedDuringScan = true;
|
|
errors.add("pull_request_identity_changed_during_scan");
|
|
} else {
|
|
pull.mergeable = detail.mergeable;
|
|
pull.detailFetched = true;
|
|
if (detail.mergeable === undefined)
|
|
errors.add("pull_request_mergeability_unknown");
|
|
}
|
|
}
|
|
return { fetched: detailsFetched, errors: [...errors] };
|
|
}
|
|
|
|
function countCategories(pulls) {
|
|
const categories = [
|
|
"missing",
|
|
"startup_failure",
|
|
"queued_pending_executing",
|
|
"approval_required",
|
|
"completed_success",
|
|
"completed_failure",
|
|
"completed_canceled",
|
|
"completed_other",
|
|
"merge_conflict",
|
|
"unknown",
|
|
"deferred",
|
|
];
|
|
return Object.fromEntries(
|
|
categories.map((category) => [
|
|
category,
|
|
pulls.filter(({ ci }) => ci.category === category).length,
|
|
]),
|
|
);
|
|
}
|
|
|
|
/** Pure status classifier; missing requires complete, identity-safe inventories. */
|
|
export function classifyPrCi({
|
|
pull,
|
|
runs,
|
|
runsComplete,
|
|
pullRequestInventoryComplete,
|
|
ambiguousRuns,
|
|
}) {
|
|
if (!pull.identityValid)
|
|
return result("unknown", "pull_request_head_identity_invalid", []);
|
|
const evidence = [...runs, ...ambiguousRuns];
|
|
if (pull.mergeable === false)
|
|
return result("merge_conflict", "github_reports_merge_conflict", evidence);
|
|
// The newest collected run is not proven current until pagination completes:
|
|
// an older-created run outside this snapshot may have been retried recently.
|
|
if (!runsComplete)
|
|
return result(
|
|
evidence.length > 0 ? "unknown" : "deferred",
|
|
evidence.length > 0
|
|
? "workflow_run_inventory_incomplete"
|
|
: "lookup_inventory_incomplete",
|
|
evidence,
|
|
);
|
|
const selection = newestRun(runs);
|
|
if (!selection.known)
|
|
return result("unknown", "workflow_run_order_unknown", evidence);
|
|
const latest = selection.latest;
|
|
if (
|
|
ambiguousRuns.some((candidate) => {
|
|
const order = latest ? compareRunEvidence(candidate, latest) : null;
|
|
return order === null || order >= 0;
|
|
})
|
|
)
|
|
return result("unknown", "workflow_run_identity_ambiguous", evidence);
|
|
if (latest !== undefined) {
|
|
const run = latest.run;
|
|
if (run.conclusion === "startup_failure")
|
|
return result("startup_failure", "workflow_startup_failure", evidence);
|
|
if (run.status === "waiting" || run.conclusion === "action_required")
|
|
return result(
|
|
"approval_required",
|
|
"workflow_waiting_for_approval_or_gate",
|
|
evidence,
|
|
);
|
|
if (["queued", "requested", "pending", "in_progress"].includes(run.status))
|
|
return result(
|
|
"queued_pending_executing",
|
|
`workflow_${run.status}`,
|
|
evidence,
|
|
);
|
|
if (run.status === "completed")
|
|
return result(
|
|
COMPLETED_CATEGORIES.get(run.conclusion) ?? "completed_other",
|
|
run.conclusion === "success"
|
|
? "workflow_succeeded"
|
|
: `workflow_${run.conclusion ?? "unknown_conclusion"}`,
|
|
evidence,
|
|
);
|
|
return result("unknown", "workflow_status_unrecognized", evidence);
|
|
}
|
|
if (!pullRequestInventoryComplete)
|
|
return result("deferred", "lookup_inventory_incomplete", []);
|
|
if (pull.mergeable !== true)
|
|
return result("unknown", "pull_request_mergeability_unknown", []);
|
|
return result("missing", "no_pull_request_event_run_for_current_head", []);
|
|
}
|
|
|
|
/** Match only a pull_request event proven to belong to this immutable PR head. */
|
|
export function matchRunToPullRequest({
|
|
run,
|
|
pull,
|
|
identityCounts,
|
|
inventoryComplete = true,
|
|
}) {
|
|
if (run?.event !== "pull_request") return { kind: "none" };
|
|
if (!pull.identityValid || !isRecord(run)) return { kind: "none" };
|
|
const association = matchExplicitPullAssociation(run, pull);
|
|
if (association === "match")
|
|
return { kind: "match", basis: "event_pr_number_and_head_sha" };
|
|
if (association === "ambiguous") return { kind: "ambiguous" };
|
|
if (association === "other" || !sameHeadRepositoryAndRef(run, pull))
|
|
return { kind: "none" };
|
|
if (!uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete))
|
|
return { kind: "ambiguous" };
|
|
if (isSha(run.head_sha) && run.head_sha.toLowerCase() === pull.head.sha)
|
|
return { kind: "match", basis: "event_unique_head_ref_and_repository_id" };
|
|
return { kind: "none" };
|
|
}
|
|
|
|
function matchExplicitPullAssociation(run, pull) {
|
|
const associations = Array.isArray(run.pull_requests)
|
|
? run.pull_requests
|
|
: [];
|
|
if (associations.length === 0) return "absent";
|
|
const matchingNumber = associations.filter(
|
|
(item) => item?.number === pull.number,
|
|
);
|
|
if (matchingNumber.length === 0) return "other";
|
|
const exactHead = matchingNumber.find(
|
|
(item) => item?.head?.sha === pull.head.sha,
|
|
);
|
|
if (exactHead === undefined) {
|
|
return matchingNumber.some(
|
|
(item) =>
|
|
typeof item?.head?.sha === "string" && item.head.sha !== pull.head.sha,
|
|
)
|
|
? "other"
|
|
: "absent";
|
|
}
|
|
const associatedHeadRepositoryId = positiveInteger(exactHead.head?.repo?.id);
|
|
const runHeadRepositoryId = positiveInteger(run.head_repository?.id);
|
|
if (associatedHeadRepositoryId === null || runHeadRepositoryId === null)
|
|
return "ambiguous";
|
|
if (
|
|
associatedHeadRepositoryId !== pull.head.repositoryId ||
|
|
runHeadRepositoryId !== pull.head.repositoryId
|
|
)
|
|
return "other";
|
|
const baseRefMatches =
|
|
typeof exactHead.base?.ref !== "string" ||
|
|
exactHead.base.ref === pull.base.ref;
|
|
const baseRepositoryId = positiveInteger(exactHead.base?.repo?.id);
|
|
const baseRepositoryMatches =
|
|
baseRepositoryId === null || baseRepositoryId === pull.base.repositoryId;
|
|
return baseRefMatches && baseRepositoryMatches ? "match" : "other";
|
|
}
|
|
|
|
function sameHeadRepositoryAndRef(run, pull) {
|
|
return (
|
|
run.head_branch === pull.head.ref &&
|
|
run.head_repository?.id === pull.head.repositoryId
|
|
);
|
|
}
|
|
|
|
function uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete) {
|
|
if (!inventoryComplete) return false;
|
|
const key = headIdentityKey(pull.head.repositoryId, pull.head.ref);
|
|
const unknownRepositoryKey = headIdentityKey(null, pull.head.ref);
|
|
return (
|
|
identityCounts.get(key) === 1 &&
|
|
(identityCounts.get(unknownRepositoryKey) ?? 0) === 0
|
|
);
|
|
}
|
|
|
|
function normalizePullRequest(raw, identity) {
|
|
const number = positiveInteger(raw?.number);
|
|
const base = normalizeBase(raw?.base);
|
|
const head = normalizeHead(raw?.head);
|
|
const draft = raw?.draft === true;
|
|
return {
|
|
number,
|
|
draft,
|
|
createdAt: typeof raw?.created_at === "string" ? raw.created_at : null,
|
|
url: typeof raw?.html_url === "string" ? raw.html_url : null,
|
|
base,
|
|
head,
|
|
mergeable: typeof raw?.mergeable === "boolean" ? raw.mergeable : undefined,
|
|
identityValid: hasValidPullIdentity({ raw, identity, number, base, head }),
|
|
};
|
|
}
|
|
|
|
function normalizeBase(raw) {
|
|
return {
|
|
ref: typeof raw?.ref === "string" ? raw.ref : null,
|
|
repositoryId: positiveInteger(raw?.repo?.id),
|
|
};
|
|
}
|
|
|
|
function normalizeHead(raw) {
|
|
return {
|
|
repositoryId: positiveInteger(raw?.repo?.id),
|
|
repository:
|
|
typeof raw?.repo?.full_name === "string" ? raw.repo.full_name : null,
|
|
ref: typeof raw?.ref === "string" ? raw.ref : null,
|
|
sha:
|
|
typeof raw?.sha === "string" && isSha(raw.sha)
|
|
? raw.sha.toLowerCase()
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function hasValidPullIdentity({ raw, identity, number, base, head }) {
|
|
return (
|
|
number !== null &&
|
|
typeof raw?.draft === "boolean" &&
|
|
base.ref === identity.defaultBranch &&
|
|
(identity.repositoryId === null ||
|
|
base.repositoryId === identity.repositoryId) &&
|
|
head.repositoryId !== null &&
|
|
head.sha !== null &&
|
|
head.ref !== null
|
|
);
|
|
}
|
|
|
|
function countHeadIdentities(pulls) {
|
|
const counts = new Map();
|
|
for (const pull of pulls) {
|
|
if (typeof pull.head.ref !== "string") continue;
|
|
const key = headIdentityKey(pull.head.repositoryId, pull.head.ref);
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function headIdentityKey(repositoryId, ref) {
|
|
return `${String(repositoryId)}\u0000${ref}`;
|
|
}
|
|
|
|
function newestRun(runs) {
|
|
let latest = runs[0];
|
|
for (const candidate of runs) {
|
|
if (latest && (compareRunEvidence(candidate, latest) ?? 0) > 0)
|
|
latest = candidate;
|
|
}
|
|
const known = runs.every((candidate) => {
|
|
if (candidate === latest) return true;
|
|
const order = compareRunEvidence(candidate, latest);
|
|
return order !== null && order <= 0;
|
|
});
|
|
return { known, latest };
|
|
}
|
|
|
|
// Attempt ordering is meaningful within one run; different run IDs are not clocks.
|
|
function compareRunEvidence(left, right) {
|
|
const leftRun = left.run;
|
|
const rightRun = right.run;
|
|
const id = positiveInteger(leftRun.id);
|
|
if (id !== null && id === positiveInteger(rightRun.id)) {
|
|
const leftAttempt = positiveInteger(leftRun.run_attempt);
|
|
const rightAttempt = positiveInteger(rightRun.run_attempt);
|
|
if (leftAttempt === null || rightAttempt === null) return null;
|
|
if (leftAttempt !== rightAttempt) return leftAttempt - rightAttempt;
|
|
return leftRun.status === rightRun.status &&
|
|
leftRun.conclusion === rightRun.conclusion
|
|
? 0
|
|
: null;
|
|
}
|
|
// A retry may start long after its original creation; without an attempt
|
|
// clock, creation dates only order first attempts from different runs.
|
|
if (
|
|
positiveInteger(leftRun.run_attempt) !== 1 ||
|
|
positiveInteger(rightRun.run_attempt) !== 1
|
|
)
|
|
return null;
|
|
const time =
|
|
Date.parse(leftRun.created_at ?? "") -
|
|
Date.parse(rightRun.created_at ?? "");
|
|
return Number.isFinite(time) && time !== 0 ? time : null;
|
|
}
|
|
|
|
function result(category, reason, runs) {
|
|
return {
|
|
category,
|
|
reason,
|
|
runs: runs.map(({ run, basis }) => ({
|
|
id: positiveInteger(run.id),
|
|
attempt: positiveInteger(run.run_attempt),
|
|
status: typeof run.status === "string" ? run.status : null,
|
|
conclusion: typeof run.conclusion === "string" ? run.conclusion : null,
|
|
headSha: isSha(run.head_sha) ? run.head_sha.toLowerCase() : null,
|
|
createdAt: typeof run.created_at === "string" ? run.created_at : null,
|
|
url: typeof run.html_url === "string" ? run.html_url : null,
|
|
identityBasis: basis,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function listOpenPullRequests({ requestGet, identity }) {
|
|
const items = [];
|
|
let complete = false;
|
|
let pages = 0;
|
|
let error = null;
|
|
for (let page = 1; page <= MAX_PULL_REQUEST_PAGES; page += 1) {
|
|
try {
|
|
const endpoint = `repos/${identity.repository}/pulls?state=open&sort=created&direction=desc&per_page=${PAGE_SIZE}&page=${page}`;
|
|
const response = await requestGet(endpoint);
|
|
if (!Array.isArray(response))
|
|
throw new Error("pull_request_page_malformed");
|
|
pages += 1;
|
|
const normalized = response.map((raw) =>
|
|
normalizePullRequest(raw, identity),
|
|
);
|
|
if (
|
|
normalized.some(
|
|
(pull) => !Number.isFinite(Date.parse(pull.createdAt ?? "")),
|
|
)
|
|
)
|
|
throw new Error("pull_request_page_malformed");
|
|
items.push(...normalized);
|
|
if (response.length < PAGE_SIZE) {
|
|
complete = true;
|
|
break;
|
|
}
|
|
} catch (cause) {
|
|
error = safeErrorCode(cause);
|
|
break;
|
|
}
|
|
}
|
|
return { items, pages, complete, error };
|
|
}
|
|
|
|
async function listRecentWorkflowRuns({ requestGet, identity, cutoff }) {
|
|
const items = [];
|
|
let complete = false;
|
|
let pages = 0;
|
|
let error = null;
|
|
for (let page = 1; page <= MAX_WORKFLOW_RUN_PAGES; page += 1) {
|
|
try {
|
|
const cutoffDate = new Date(cutoff).toISOString().slice(0, 10);
|
|
const endpoint = `repos/${identity.repository}/actions/workflows/ci.yml/runs?event=pull_request&created=%3E%3D${cutoffDate}&per_page=${PAGE_SIZE}&page=${page}`;
|
|
const response = await requestGet(endpoint);
|
|
if (!isRecord(response) || !Array.isArray(response.workflow_runs))
|
|
throw new Error("workflow_run_page_malformed");
|
|
pages += 1;
|
|
if (!response.workflow_runs.every(isRecord))
|
|
throw new Error("workflow_run_page_malformed");
|
|
items.push(...response.workflow_runs);
|
|
if (response.workflow_runs.length < PAGE_SIZE) {
|
|
complete = true;
|
|
break;
|
|
}
|
|
} catch (cause) {
|
|
error = safeErrorCode(cause);
|
|
break;
|
|
}
|
|
}
|
|
return { items, pages, complete, error };
|
|
}
|
|
|
|
async function getPullRequestDetails({ requestGet, identity, pull }) {
|
|
if (pull.number === null)
|
|
return { detail: null, error: "pull_request_detail_identity_missing" };
|
|
try {
|
|
const response = await requestGet(
|
|
`repos/${identity.repository}/pulls/${pull.number}`,
|
|
);
|
|
if (!isRecord(response))
|
|
return { detail: null, error: "pull_request_detail_malformed" };
|
|
return {
|
|
detail: {
|
|
mergeable:
|
|
typeof response.mergeable === "boolean"
|
|
? response.mergeable
|
|
: undefined,
|
|
headSha: isSha(response.head?.sha)
|
|
? response.head.sha.toLowerCase()
|
|
: null,
|
|
headRef:
|
|
typeof response.head?.ref === "string" ? response.head.ref : null,
|
|
headRepositoryId: positiveInteger(response.head?.repo?.id),
|
|
baseRef:
|
|
typeof response.base?.ref === "string" ? response.base.ref : null,
|
|
baseRepositoryId: positiveInteger(response.base?.repo?.id),
|
|
},
|
|
error: null,
|
|
};
|
|
} catch (cause) {
|
|
return { detail: null, error: safeErrorCode(cause) };
|
|
}
|
|
}
|
|
|
|
function validateInputs({
|
|
repository,
|
|
repositoryId,
|
|
defaultBranch,
|
|
lookbackDays,
|
|
now,
|
|
}) {
|
|
if (
|
|
typeof repository !== "string" ||
|
|
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
|
)
|
|
throw new Error(
|
|
"CI_REPOSITORY must be an owner/name repository identifier",
|
|
);
|
|
if (
|
|
typeof defaultBranch !== "string" ||
|
|
defaultBranch.length === 0 ||
|
|
/[\r\n]/u.test(defaultBranch)
|
|
)
|
|
throw new Error("CI_DEFAULT_BRANCH must be a nonempty branch name");
|
|
const parsedRepositoryId =
|
|
repositoryId === undefined || repositoryId === null || repositoryId === ""
|
|
? null
|
|
: positiveInteger(repositoryId);
|
|
if (
|
|
repositoryId !== undefined &&
|
|
repositoryId !== null &&
|
|
repositoryId !== "" &&
|
|
parsedRepositoryId === null
|
|
)
|
|
throw new Error("CI_REPOSITORY_ID must be a positive safe integer");
|
|
if (
|
|
!Number.isInteger(lookbackDays) ||
|
|
lookbackDays < 1 ||
|
|
lookbackDays > MAX_LOOKBACK_DAYS
|
|
)
|
|
throw new Error(
|
|
`lookbackDays must be an integer from 1 to ${String(MAX_LOOKBACK_DAYS)}`,
|
|
);
|
|
if (!Number.isFinite(now)) throw new Error("now must be a finite timestamp");
|
|
return {
|
|
repository,
|
|
repositoryId: parsedRepositoryId,
|
|
defaultBranch,
|
|
lookbackDays,
|
|
};
|
|
}
|
|
|
|
function positiveInteger(value) {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
|
}
|
|
|
|
function isSha(value) {
|
|
return typeof value === "string" && SHA_PATTERN.test(value);
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function safeErrorCode(cause) {
|
|
if (cause?.message === "lookup_time_budget_exhausted")
|
|
return "lookup_time_budget_exhausted";
|
|
if (cause?.message === "lookup_request_budget_exhausted")
|
|
return "lookup_request_budget_exhausted";
|
|
if (cause?.message === "github_api_malformed_json")
|
|
return "github_api_malformed_json";
|
|
if (cause?.message?.endsWith("_malformed")) return cause.message;
|
|
if (cause?.code === "ETIMEDOUT" || cause?.killed) return "github_api_timeout";
|
|
return "github_api_request_failed";
|
|
}
|
|
|
|
export async function writePrCiHealthReport(
|
|
report,
|
|
outputDirectory = OUTPUT_DIRECTORY,
|
|
) {
|
|
const jsonPath = join(outputDirectory, "report.json");
|
|
const markdownPath = join(outputDirectory, "summary.md");
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
await writeFile(markdownPath, renderMarkdownSummary(report), "utf8");
|
|
return { jsonPath, markdownPath };
|
|
}
|
|
|
|
export function renderMarkdownSummary(report) {
|
|
const rows = report.pullRequests.map(
|
|
(pull) =>
|
|
`| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`,
|
|
);
|
|
return [
|
|
"# Pull request CI health snapshot",
|
|
"",
|
|
`Observed ${report.observedAt} for \`${report.repository}\`, target branch \`${escapeCell(report.defaultBranch)}\`; recent window ${String(report.lookbackDays)} days. Scan: **${report.scan.status}**.`,
|
|
"",
|
|
"This is read-only point-in-time telemetry. Missing is reported only when PR and workflow-run inventories are complete and the current immutable PR head identity is unambiguous. Incomplete lookups remain deferred or unknown.",
|
|
"",
|
|
`API pages: ${String(report.scan.pullRequestPages)} PR, ${String(report.scan.workflowRunPages)} workflow runs; ${String(report.scan.apiRequests)} bounded requests. Errors: ${report.scan.errors.length === 0 ? "none" : report.scan.errors.join(", ")}.`,
|
|
"",
|
|
"| PR | Head repository | Head ref | Head SHA | CI classification | Evidence |",
|
|
"| ---: | --- | --- | --- | --- | --- |",
|
|
...rows,
|
|
"",
|
|
"Categories: missing, startup failure, queued/pending/executing, approval required, completed outcomes, merge conflict, unknown, and deferred.",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function escapeCell(value) {
|
|
return String(value)
|
|
.replaceAll("|", "\\|")
|
|
.replaceAll("\n", " ")
|
|
.replaceAll("\r", " ");
|
|
}
|
|
|
|
if (
|
|
process.argv[1] !== undefined &&
|
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
try {
|
|
const report = await collectPrCiHealth({
|
|
repository: process.env.CI_REPOSITORY,
|
|
repositoryId: process.env.CI_REPOSITORY_ID,
|
|
defaultBranch: process.env.CI_DEFAULT_BRANCH,
|
|
});
|
|
const paths = await writePrCiHealthReport(
|
|
report,
|
|
process.env.CI_PR_HEALTH_DIRECTORY ?? OUTPUT_DIRECTORY,
|
|
);
|
|
if (process.env.GITHUB_STEP_SUMMARY)
|
|
await appendFile(
|
|
process.env.GITHUB_STEP_SUMMARY,
|
|
renderMarkdownSummary(report),
|
|
"utf8",
|
|
);
|
|
process.stdout.write(
|
|
`Wrote ${report.pullRequests.length} recent PR CI records to ${paths.jsonPath}\n`,
|
|
);
|
|
} catch (cause) {
|
|
process.stderr.write(
|
|
`PR CI health telemetry failed: ${cause instanceof Error ? cause.message : "unknown_error"}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|