193 lines
6.2 KiB
JavaScript
193 lines
6.2 KiB
JavaScript
function getSleepLength(iterationCount, numPendingJobs) {
|
|
if (iterationCount <= 5 && numPendingJobs <= 5) {
|
|
// It's likely that this job was triggered with other quick jobs.
|
|
// To minimize the wait time, shorten the polling interval for the first 5 iterations.
|
|
return 5 * 1000; // 5 seconds
|
|
}
|
|
// If the number of pending jobs is small, poll more frequently to reduce wait time.
|
|
return (numPendingJobs <= 7 ? 30 : 5 * 60) * 1000;
|
|
}
|
|
module.exports = async ({ github, context }) => {
|
|
const {
|
|
repo: { owner, repo },
|
|
} = context;
|
|
const { sha } = context.payload.pull_request.head;
|
|
|
|
const STATE = {
|
|
pending: "pending",
|
|
success: "success",
|
|
failure: "failure",
|
|
};
|
|
|
|
const IGNORED_WORKFLOWS = new Set([".github/workflows/rerun.yml"]);
|
|
|
|
async function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function logRateLimit() {
|
|
const { data: rateLimit } = await github.rest.rateLimit.get();
|
|
console.log(`Rate limit remaining: ${rateLimit.resources.core.remaining}`);
|
|
}
|
|
|
|
function isNewerRun(newRun, existingRun) {
|
|
// Returns true if newRun should replace existingRun
|
|
if (!existingRun) return true;
|
|
|
|
// If they are different workflow runs, prefer the one with a higher ID (auto-incrementing)
|
|
if (newRun.id !== existingRun.id) {
|
|
return newRun.id > existingRun.id;
|
|
}
|
|
|
|
// Same workflow run: higher run_attempt takes priority (re-runs)
|
|
return newRun.run_attempt > existingRun.run_attempt;
|
|
}
|
|
function isJobFailed({ status, conclusion }) {
|
|
return (
|
|
conclusion === "cancelled" ||
|
|
(status === "completed" && conclusion !== "success" && conclusion !== "skipped")
|
|
);
|
|
}
|
|
|
|
async function fetchChecks(ref) {
|
|
// Check runs (e.g., DCO check, but excluding GitHub Actions)
|
|
const checkRuns = (
|
|
await github.paginate(github.rest.checks.listForRef, {
|
|
owner,
|
|
repo,
|
|
ref,
|
|
filter: "latest",
|
|
per_page: 100,
|
|
})
|
|
).filter(({ app }) => app?.slug !== "github-actions");
|
|
|
|
const latestCheckRuns = {};
|
|
for (const run of checkRuns) {
|
|
const { name } = run;
|
|
if (
|
|
!latestCheckRuns[name] ||
|
|
new Date(run.started_at) > new Date(latestCheckRuns[name].started_at)
|
|
) {
|
|
latestCheckRuns[name] = run;
|
|
}
|
|
}
|
|
const checks = Object.values(latestCheckRuns).map(({ name, status, conclusion, html_url }) => ({
|
|
name,
|
|
url: html_url,
|
|
pendingJobs: 0,
|
|
status:
|
|
conclusion === "cancelled"
|
|
? STATE.failure
|
|
: status !== "completed"
|
|
? STATE.pending
|
|
: conclusion === "success" || conclusion === "skipped"
|
|
? STATE.success
|
|
: STATE.failure,
|
|
}));
|
|
|
|
// Workflow runs (e.g., GitHub Actions)
|
|
const workflowRuns = (
|
|
await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
|
owner,
|
|
repo,
|
|
head_sha: ref,
|
|
per_page: 100,
|
|
})
|
|
).filter(
|
|
({ path, event }) =>
|
|
// Exclude this workflow to avoid self-checking
|
|
path !== ".github/workflows/protect.yml" &&
|
|
// Exclude dynamic workflows (GitHub-managed, e.g., Copilot code review)
|
|
event !== "dynamic" &&
|
|
!IGNORED_WORKFLOWS.has(path)
|
|
);
|
|
|
|
// Deduplicate workflow runs by path and event, keeping the latest attempt
|
|
const latestRuns = {};
|
|
for (const run of workflowRuns) {
|
|
const { path, event } = run;
|
|
const key = `${path}-${event}`;
|
|
if (isNewerRun(run, latestRuns[key])) {
|
|
latestRuns[key] = run;
|
|
}
|
|
}
|
|
|
|
for (const run of Object.values(latestRuns)) {
|
|
const runName = run.path.replace(".github/workflows/", "");
|
|
if (run.status === "completed") {
|
|
// Use run-level status directly (0 extra API calls).
|
|
checks.push({
|
|
name: `${run.name} (${runName}, attempt ${run.run_attempt})`,
|
|
url: run.html_url,
|
|
pendingJobs: 0,
|
|
status:
|
|
run.conclusion === "cancelled"
|
|
? STATE.failure
|
|
: run.conclusion === "success" || run.conclusion === "skipped"
|
|
? STATE.success
|
|
: STATE.failure,
|
|
});
|
|
} else {
|
|
let failed = false;
|
|
let pendingJobs = 0;
|
|
const jobsIter = github.paginate.iterator(github.rest.actions.listJobsForWorkflowRun, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
per_page: 100,
|
|
});
|
|
for await (const { data: jobs } of jobsIter) {
|
|
if (jobs.some(isJobFailed)) {
|
|
failed = true;
|
|
break;
|
|
}
|
|
pendingJobs += jobs.filter(({ status }) => status !== "completed").length;
|
|
}
|
|
checks.push({
|
|
name: `${run.name} (${runName}, attempt ${run.run_attempt})`,
|
|
url: run.html_url,
|
|
pendingJobs: failed ? 0 : pendingJobs,
|
|
status: failed ? STATE.failure : STATE.pending,
|
|
});
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
const start = new Date();
|
|
let iterationCount = 0;
|
|
const TIMEOUT = 120 * 60 * 1000; // 2 hours
|
|
await logRateLimit();
|
|
while (new Date() - start < TIMEOUT) {
|
|
++iterationCount;
|
|
const checks = await fetchChecks(sha);
|
|
const longest = Math.max(...checks.map(({ name }) => name.length));
|
|
checks.forEach(({ name, status, url }) => {
|
|
const icon = status === STATE.success ? "✅" : status === STATE.failure ? "❌" : "🕒";
|
|
console.log(`- ${name.padEnd(longest)}: ${icon} ${status}${url ? ` (${url})` : ""}`);
|
|
});
|
|
|
|
if (checks.some(({ status }) => status === STATE.failure)) {
|
|
throw new Error(
|
|
"This job ensures that all checks except for this one have passed to prevent accidental auto-merges."
|
|
);
|
|
}
|
|
|
|
if (checks.length > 0 && checks.every(({ status }) => status === STATE.success)) {
|
|
console.log("All checks passed");
|
|
return;
|
|
}
|
|
|
|
await logRateLimit();
|
|
const pendingJobs = checks
|
|
.filter(({ status }) => status === STATE.pending)
|
|
.reduce((sum, check) => sum + check.pendingJobs, 0);
|
|
const sleepLength = getSleepLength(iterationCount, pendingJobs);
|
|
console.log(`Sleeping for ${sleepLength / 1000} seconds (${pendingJobs} pending jobs)`);
|
|
await sleep(sleepLength);
|
|
}
|
|
|
|
throw new Error("Timeout");
|
|
};
|