Files
orca/config/scripts/ci-runner-demand.mjs
T

122 lines
5.2 KiB
JavaScript

import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs'
async function api(path, env) {
// Hundreds of calls per report make transient 5xx responses routine.
for (let attempt = 1; ; attempt++) {
const response = await fetch(
`${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${env.GITHUB_REPOSITORY}/${path}`,
{
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
},
signal: AbortSignal.timeout(30_000)
}
)
if (response.ok) {
return response.json()
}
if (response.status < 500 || attempt === 3) {
throw new Error(`Actions API: ${response.status}`)
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000))
}
}
// The runs API serves at most 1000 results per query, so busy intervals are halved until each fits.
async function collectRuns(sinceMs, untilMs, request) {
const range = `${new Date(sinceMs).toISOString()}..${new Date(untilMs).toISOString()}`
const path = `actions/runs?per_page=100&created=${encodeURIComponent(range)}`
const first = await request(path)
if (first.total_count > 1000) {
const seconds = (untilMs - sinceMs) / 1000 + 1
if (seconds < 2) {
throw new Error('Run inventory exceeds API limit within one second')
}
const middle = sinceMs + Math.floor(seconds / 2) * 1000
return [
...(await collectRuns(sinceMs, middle - 1000, request)),
...(await collectRuns(middle, untilMs, request))
]
}
const runs = [...first.workflow_runs]
for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) {
runs.push(...(await request(`${path}&page=${page}`)).workflow_runs)
}
return runs
}
export async function collectRunnerDemand(env = process.env, request = (path) => api(path, env)) {
const end = new Date(env.CI_METRICS_END ?? Date.now())
end.setUTCMinutes(0, 0, 0)
if (!Number.isFinite(end.getTime())) {
throw new Error('Invalid window end')
}
const runs = []
for (let hour = 0; hour < 24; hour++) {
const until = end.getTime() - hour * 3_600_000 - 1000
runs.push(...(await collectRuns(until - 3_599_000, until, request)))
}
const samples = sampleWorkflowRuns(runs)
// Bound API concurrency and preserve every selected observation, including zero-job runs.
for (let index = 0; index < samples.length; index += 4) {
await Promise.all(
samples.slice(index, index + 4).map(async (sample) => {
const path = `actions/runs/${sample.run.id}/jobs?per_page=100&filter=all`
const first = await request(path)
sample.jobs = first.jobs
for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) {
sample.jobs.push(...(await request(`${path}&page=${page}`)).jobs)
}
})
)
}
const report = {
start: new Date(end.getTime() - 86_400_000).toISOString(),
end: end.toISOString(),
method:
'Stratified by latest workflow/conclusion, six sampled runs per stratum; all attempts included; incomplete jobs excluded; run outcome uses the latest attempt',
...runnerDemand(runs, samples)
}
return { report, runs, samples }
}
export function demandMarkdown(report) {
return [
`## CI demand: ${report.start} to ${report.end}`,
'',
`Estimated full job duration for runs created in this window (not window-clipped occupancy); ${report.sampledRuns}/${report.populationRuns} runs sampled. ${report.method}.`,
'',
'| Workflow | Runs | Runner hours | Cancelled-run hours | Repeated green-job hours | Minutes/completed PR run |',
'| --- | ---: | ---: | ---: | ---: | ---: |',
...report.workflows.map(
(row) =>
`| ${row.workflow} | ${row.runs} | ${(row.runnerMinutes / 60).toFixed(1)} | ${(row.cancelledRunnerMinutes / 60).toFixed(1)} | ${(row.repeatedSuccessfulRunnerMinutes / 60).toFixed(1)} | ${row.runnerMinutesPerCompletedPrRun?.toFixed(1) ?? '—'} |`
),
'',
'Completed records with the same job name, runner and execution timestamps across attempts are counted once: GitHub carries successful results forward without running them again. Repeated green-job hours count fresh executions after a successful job of the same name in an earlier attempt. This review bucket is not guaranteed avoidable work; it overlaps total and cancelled-run hours.',
'',
'| Runner labels | Hours | Queue/provisioning p95 minutes |',
'| --- | ---: | ---: |',
...report.pools.map(
(pool) =>
`| ${pool.label} | ${(pool.runnerMinutes / 60).toFixed(1)} | ${pool.queueP95Minutes?.toFixed(1) ?? '—'} |`
),
''
].join('\n')
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const evidence = await collectRunnerDemand()
mkdirSync('ci-demand', { recursive: true })
writeFileSync('ci-demand/evidence.json', JSON.stringify(evidence))
const markdown = demandMarkdown(evidence.report)
writeFileSync('ci-demand/report.md', markdown)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown)
}
}