ci-sdk 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -1
- package/dist/classify.js +31 -0
- package/dist/cli.js +80 -0
- package/dist/github.js +171 -0
- package/dist/graph.js +315 -0
- package/dist/lockfile.js +83 -0
- package/dist/purity.js +140 -0
- package/dist/report.js +104 -0
- package/dist/run.js +257 -0
- package/dist/scan.js +157 -0
- package/dist/test.js +112 -0
- package/package.json +33 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diffling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
1
|
# ci-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Insanely fast CI. No migration. By [Diffling](https://diffling.com).
|
|
4
|
+
|
|
5
|
+
ci-sdk makes the CI you already have skip work your change can't affect: packages whose inputs didn't change, tests your change can't reach, and jobs that already passed on the same code. No pipeline to rewrite, no config, any runner.
|
|
6
|
+
|
|
7
|
+
On [React Router](https://github.com/remix-run/react-router) (10 packages), whole CI jobs (setup, install, build, typecheck, lint, unit tests) ran **40% faster on pushes and 27% faster on pull requests**, with the same results, replaying real commits on GitHub's standard runners. Gains are largest in monorepos with many packages and commits that touch a few of them; repos whose jobs take only seconds gain little.
|
|
8
|
+
|
|
9
|
+
Needs Node 22.18 or newer. Works with pnpm and npm workspaces (yarn and bun run through their own `run`).
|
|
10
|
+
|
|
11
|
+
## run
|
|
12
|
+
|
|
13
|
+
Run a package script in every workspace package, skipping packages whose inputs haven't changed since they last passed:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx ci-sdk run build
|
|
17
|
+
npx ci-sdk run typecheck
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- A package reruns when its own files, a workspace package it depends on, or its dependencies in `pnpm-lock.yaml` change. Docs, changelogs, licenses and CI config don't count.
|
|
21
|
+
- Outputs are whatever the script creates outside git. On a cache hit they are restored and the script's log is replayed. No config.
|
|
22
|
+
- Packages run dependencies first, up to 4 at a time. Failures are never cached, and packages that depend on a failure don't run.
|
|
23
|
+
- The cache lives in `node_modules/.cache/ci-sdk`. In CI, entries the run didn't use are pruned, so a saved cache stays small.
|
|
24
|
+
- `--filter './packages/*'` limits it to some folders. `--no-cache` runs everything and refreshes the cache.
|
|
25
|
+
- Environment variables are not part of a package's inputs. If a script's output depends on one, run it with `--no-cache`.
|
|
26
|
+
|
|
27
|
+
## test
|
|
28
|
+
|
|
29
|
+
Run only the tests your changes can reach:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx ci-sdk test # uncommitted and untracked changes
|
|
33
|
+
npx ci-sdk test --since origin/main # plus commits on your branch
|
|
34
|
+
npx ci-sdk test --since HEAD~1 # in CI: the commit being tested
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Follows imports through barrel files, so a change in a shared package only runs the tests that use it.
|
|
38
|
+
- Runs each package's own test script with the affected files. Packages without one run through the root's script, such as `jest` or `vitest run`. `--script test-unit` picks a different script.
|
|
39
|
+
- If a change can affect everything, such as a root config, it runs the full suite.
|
|
40
|
+
- It never reports success for tests it couldn't run: if no script can take the affected files, it fails and names them.
|
|
41
|
+
- `--list` prints the affected test files instead of running them.
|
|
42
|
+
|
|
43
|
+
## GitHub Action
|
|
44
|
+
|
|
45
|
+
Restores tool caches (Turborepo, Next.js, ESLint, `node_modules/.cache`, which includes ci-sdk's own) and tells you when this exact code already passed this job:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v6
|
|
50
|
+
with:
|
|
51
|
+
fetch-depth: 2 # lets `ci-sdk test --since HEAD~1` see the commit's changes
|
|
52
|
+
- uses: pnpm/action-setup@v6
|
|
53
|
+
- uses: actions/setup-node@v7
|
|
54
|
+
with:
|
|
55
|
+
node-version: 24
|
|
56
|
+
- id: ci-sdk
|
|
57
|
+
uses: jaymalve/ci-sdk@v0.0.3
|
|
58
|
+
- if: steps.ci-sdk.outputs.already-passed != 'true'
|
|
59
|
+
run: |
|
|
60
|
+
pnpm install --frozen-lockfile
|
|
61
|
+
npx ci-sdk run build
|
|
62
|
+
npx ci-sdk run typecheck
|
|
63
|
+
npx ci-sdk test --since HEAD~1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Add `ci-sdk` to your devDependencies to pin its version; `npx` then uses the installed copy instead of downloading it.
|
|
67
|
+
- `already-passed` is `true` when this job already passed on the same code (same git tree, job and matrix). Skip the rest of the job when it is.
|
|
68
|
+
- Each run saves a fresh cache entry and restores the latest one. On pull requests, that includes the base branch's.
|
|
69
|
+
- On runners that serve the GitHub Actions cache API from their own storage, such as Blacksmith and Depot, the Action uses that automatically.
|
|
70
|
+
|
|
71
|
+
## scan
|
|
72
|
+
|
|
73
|
+
See where your GitHub Actions minutes and dollars go, and how much ci-sdk would save. Run it in your repo:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx ci-sdk scan
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- Looks at the last 30 days of workflow runs (`--days`, `--max-runs` to change).
|
|
80
|
+
- Estimates savings from your repo's own history: repeated runs, tests a change could not reach (via an oxc import graph), and slow installs.
|
|
81
|
+
- `--markdown` for a PR or Slack, `--json` for tooling.
|
|
82
|
+
- Needs a GitHub token with **Actions: read**. Uses `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`.
|
|
83
|
+
- Runs locally. Your code and CI data never leave your machine.
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Order matters: first match wins, so "Setup pnpm" is setup and "Run pnpm test" is test.
|
|
2
|
+
const RULES = [
|
|
3
|
+
// pnpm/action-setup usually runs the install itself (run_install: true); when it doesn't, it takes seconds.
|
|
4
|
+
['install', /pnpm\/action-setup/i],
|
|
5
|
+
['setup', /^(set up job|complete job|post )|checkout|\bset ?up\b|actions\/cache/i],
|
|
6
|
+
['install', /\b(npm (ci|install|i)|pnpm (i|install)|yarn install|bun install|deno install|install dependencies)\b|^run yarn$|^install( deps| dependencies| packages)?$/i],
|
|
7
|
+
['test', /\b(test|tests|vitest|jest|spec|e2e|playwright|cypress|coverage)\b/i],
|
|
8
|
+
['lint', /\b(lint|eslint|oxlint|biome|prettier|format|typecheck|type-check|tsc|check)\b/i],
|
|
9
|
+
['build', /\b(build|compile|bundle|tsup|rollup|webpack)\b/i],
|
|
10
|
+
];
|
|
11
|
+
export function categorize(stepName, durationMs = 0) {
|
|
12
|
+
const cat = RULES.find(([, re]) => re.test(stepName))?.[0] ?? 'other';
|
|
13
|
+
// Custom setup actions ("Setup pnpm and Node.js") often install deps too; installing a toolchain alone takes seconds.
|
|
14
|
+
if (cat === 'setup' && durationMs > 60_000 && /pnpm|npm|yarn|bun|node|deps|dependencies/i.test(stepName))
|
|
15
|
+
return 'install';
|
|
16
|
+
return cat;
|
|
17
|
+
}
|
|
18
|
+
// GitHub's published per-minute USD rates: https://docs.github.com/en/billing/reference/actions-minute-multipliers
|
|
19
|
+
// shortcut: larger-runner sizes are not detected and are priced as 2-core; read runner size from the org's runner API when we need exact larger-runner pricing.
|
|
20
|
+
export function ratePerMinute(labels) {
|
|
21
|
+
const l = labels.join(' ').toLowerCase();
|
|
22
|
+
if (l.includes('self-hosted'))
|
|
23
|
+
return null;
|
|
24
|
+
if (l.includes('macos'))
|
|
25
|
+
return 0.062;
|
|
26
|
+
if (l.includes('windows'))
|
|
27
|
+
return 0.01;
|
|
28
|
+
if (l.includes('arm'))
|
|
29
|
+
return 0.005;
|
|
30
|
+
return 0.006;
|
|
31
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { getToken, repoFromGit } from './github.js';
|
|
4
|
+
import { json, markdown, text } from './report.js';
|
|
5
|
+
import { runCached } from './run.js';
|
|
6
|
+
import { scan } from './scan.js';
|
|
7
|
+
import { testAffected } from './test.js';
|
|
8
|
+
const HELP = `Usage: ci-sdk <command> [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
run Run a package script in every workspace package, skipping packages whose inputs already passed
|
|
12
|
+
test Run only the tests your changes can reach
|
|
13
|
+
scan Show where your GitHub Actions minutes and dollars go, and what ci-sdk would save
|
|
14
|
+
|
|
15
|
+
run options: ci-sdk run <script>
|
|
16
|
+
--filter <glob> Only packages in matching folders, e.g. './packages/*' (repeatable)
|
|
17
|
+
--concurrency <n> Packages at a time (default: up to 4)
|
|
18
|
+
--no-cache Run everything and refresh the cache
|
|
19
|
+
|
|
20
|
+
test options:
|
|
21
|
+
--since <ref> Also include changes committed since <ref>, e.g. origin/main (default: uncommitted only)
|
|
22
|
+
--list Print the affected test files instead of running them
|
|
23
|
+
--script <name> Script that runs tests and takes file paths (default: test), e.g. test-unit
|
|
24
|
+
|
|
25
|
+
scan options:
|
|
26
|
+
--repo <owner/name> Repo to scan (default: this checkout's origin)
|
|
27
|
+
--days <n> Days of history (default: 30)
|
|
28
|
+
--max-runs <n> Runs to sample evenly across the window (default: 300)
|
|
29
|
+
--json Print JSON
|
|
30
|
+
--markdown Print markdown to paste into a PR or Slack
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
|
|
33
|
+
scan needs a GitHub token with Actions: read. Uses GITHUB_TOKEN, GH_TOKEN, or \`gh auth token\`.
|
|
34
|
+
Everything runs locally; nothing leaves your machine.`;
|
|
35
|
+
async function main() {
|
|
36
|
+
const { values, positionals } = parseArgs({
|
|
37
|
+
allowPositionals: true,
|
|
38
|
+
options: {
|
|
39
|
+
repo: { type: 'string' },
|
|
40
|
+
days: { type: 'string', default: '30' },
|
|
41
|
+
'max-runs': { type: 'string', default: '300' },
|
|
42
|
+
json: { type: 'boolean' },
|
|
43
|
+
markdown: { type: 'boolean' },
|
|
44
|
+
since: { type: 'string' },
|
|
45
|
+
list: { type: 'boolean' },
|
|
46
|
+
script: { type: 'string' },
|
|
47
|
+
filter: { type: 'string', multiple: true },
|
|
48
|
+
concurrency: { type: 'string' },
|
|
49
|
+
'no-cache': { type: 'boolean' },
|
|
50
|
+
help: { type: 'boolean', short: 'h' },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const command = positionals[0];
|
|
54
|
+
if (values.help || !['scan', 'test', 'run'].includes(command) || (command === 'run' && !positionals[1])) {
|
|
55
|
+
console.log(HELP);
|
|
56
|
+
process.exit(values.help ? 0 : 1);
|
|
57
|
+
}
|
|
58
|
+
const log = (m) => process.stderr.write(`ci-sdk: ${m}\n`);
|
|
59
|
+
if (command === 'run')
|
|
60
|
+
process.exit(await runCached({ cwd: process.cwd(), script: positionals[1], filter: values.filter, concurrency: values.concurrency ? Number(values.concurrency) : undefined, noCache: values['no-cache'], log }));
|
|
61
|
+
if (command === 'test')
|
|
62
|
+
process.exit(await testAffected({ cwd: process.cwd(), since: values.since, list: values.list, script: values.script, log }));
|
|
63
|
+
const root = process.cwd();
|
|
64
|
+
const repo = values.repo ?? repoFromGit(root);
|
|
65
|
+
if (!repo)
|
|
66
|
+
throw new Error('Could not find a GitHub remote. Run inside a clone, or pass --repo owner/name.');
|
|
67
|
+
const token = getToken();
|
|
68
|
+
if (!token)
|
|
69
|
+
throw new Error('No GitHub token found. Set GITHUB_TOKEN (a fine-grained token with Actions: read), or run `gh auth login`.');
|
|
70
|
+
const days = Number(values.days);
|
|
71
|
+
const maxRuns = Number(values['max-runs']);
|
|
72
|
+
if (!(days > 0) || !(maxRuns > 0))
|
|
73
|
+
throw new Error('--days and --max-runs must be positive numbers.');
|
|
74
|
+
const result = await scan({ root, repo, token, days, maxRuns, log: (m) => process.stderr.write(m + '\n') });
|
|
75
|
+
console.log(values.json ? json(result) : values.markdown ? markdown(result) : text(result));
|
|
76
|
+
}
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(`ci-sdk: ${err.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const API = 'https://api.github.com';
|
|
3
|
+
export function getToken() {
|
|
4
|
+
const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
5
|
+
if (env)
|
|
6
|
+
return env;
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function repoFromGit(cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
17
|
+
return url.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)?.[1];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function gh(path, token, attempt = 1) {
|
|
24
|
+
const res = await fetch(API + path, {
|
|
25
|
+
headers: {
|
|
26
|
+
Accept: 'application/vnd.github+json',
|
|
27
|
+
Authorization: `Bearer ${token}`,
|
|
28
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (res.ok)
|
|
32
|
+
return res.json();
|
|
33
|
+
const outOfQuota = res.headers.get('x-ratelimit-remaining') === '0';
|
|
34
|
+
// Flaky 5xx and secondary rate limits (Retry-After) clear up; a spent hourly quota does not.
|
|
35
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
36
|
+
if (attempt < 4 && (res.status >= 500 || res.status === 429 || (res.status === 403 && retryAfter && !outOfQuota))) {
|
|
37
|
+
await new Promise((r) => setTimeout(r, retryAfter ? retryAfter * 1000 : 1000 * 2 ** attempt));
|
|
38
|
+
return gh(path, token, attempt + 1);
|
|
39
|
+
}
|
|
40
|
+
const body = (await res.json().catch(() => ({})));
|
|
41
|
+
const reset = new Date(Number(res.headers.get('x-ratelimit-reset')) * 1000).toLocaleTimeString();
|
|
42
|
+
const hint = res.status === 401 ? ' (the token is invalid or expired)'
|
|
43
|
+
: outOfQuota ? ` (GitHub API rate limit used up; it resets at ${reset})`
|
|
44
|
+
: res.status === 403 ? ' (the token needs Actions: read on this repo)'
|
|
45
|
+
: res.status === 404 ? ' (check the repo name and that your token can read it)'
|
|
46
|
+
: '';
|
|
47
|
+
throw new Error(`GitHub API ${res.status} on ${path}: ${body.message ?? res.statusText}${hint}`);
|
|
48
|
+
}
|
|
49
|
+
export function getRepo(repo, token) {
|
|
50
|
+
return gh(`/repos/${repo}`, token);
|
|
51
|
+
}
|
|
52
|
+
const COUNT_CAP = 2500;
|
|
53
|
+
/** How many runs a slot held. Past GitHub's 2,500 count cap, scale the spacing of the newest 100 runs to the whole slot. */
|
|
54
|
+
export function slotCount(totalCount, runs, width) {
|
|
55
|
+
if (totalCount < COUNT_CAP || runs.length < 2)
|
|
56
|
+
return totalCount;
|
|
57
|
+
const span = Date.parse(runs[0].created_at) - Date.parse(runs.at(-1).created_at);
|
|
58
|
+
return span > 0 ? Math.max(COUNT_CAP, Math.round(((runs.length - 1) * width) / span)) : COUNT_CAP;
|
|
59
|
+
}
|
|
60
|
+
/** `n` items spread evenly through `items`, so a slot's sample is not just its last minutes. */
|
|
61
|
+
const spread = (items, n) => (items.length <= n ? items : Array.from({ length: n }, (_, i) => items[Math.floor((i * items.length) / n)]));
|
|
62
|
+
/**
|
|
63
|
+
* Splits `budget` samples across workflows by `score`, never more than a workflow's run count.
|
|
64
|
+
* Samples a small workflow cannot use go back to the others, so the whole budget gets spent.
|
|
65
|
+
*/
|
|
66
|
+
export function allocate(items, budget) {
|
|
67
|
+
const k = items.map(() => 0);
|
|
68
|
+
let open = items.map((_, i) => i).filter((i) => items[i].count > 0);
|
|
69
|
+
let left = budget;
|
|
70
|
+
while (left > 0 && open.length) {
|
|
71
|
+
const total = open.reduce((a, i) => a + items[i].score, 0) || 1;
|
|
72
|
+
let used = 0;
|
|
73
|
+
for (const i of open) {
|
|
74
|
+
const next = Math.min(items[i].count, k[i] + Math.max(1, Math.floor((left * items[i].score) / total)));
|
|
75
|
+
used += next - k[i];
|
|
76
|
+
k[i] = next;
|
|
77
|
+
}
|
|
78
|
+
left -= used;
|
|
79
|
+
open = open.filter((i) => k[i] < items[i].count);
|
|
80
|
+
}
|
|
81
|
+
return k;
|
|
82
|
+
}
|
|
83
|
+
const iso = (t) => new Date(t).toISOString().slice(0, 19) + 'Z';
|
|
84
|
+
const wallMs = (r) => Math.max(1000, Date.parse(r.updated_at ?? r.created_at) - Date.parse(r.run_started_at ?? r.created_at));
|
|
85
|
+
/**
|
|
86
|
+
* `k` runs of one workflow spread over time, each weighted by how many runs its time slot held.
|
|
87
|
+
* Small workflows are sampled straight from their first page; busy ones get one query per slot.
|
|
88
|
+
*/
|
|
89
|
+
async function sampleWorkflow(repo, token, id, first, since, now, k) {
|
|
90
|
+
// Up to GitHub's 1,000-result listing limit, list every run (1 call per 100) and pick evenly: exact count, no empty slots.
|
|
91
|
+
if (first.total_count <= 1000) {
|
|
92
|
+
const all = [...first.workflow_runs];
|
|
93
|
+
const created = encodeURIComponent(`>=${iso(since).slice(0, 10)}`);
|
|
94
|
+
for (let page = 2; all.length < first.total_count; page++) {
|
|
95
|
+
const data = await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100&page=${page}`, token);
|
|
96
|
+
if (!data.workflow_runs.length)
|
|
97
|
+
break;
|
|
98
|
+
all.push(...data.workflow_runs);
|
|
99
|
+
}
|
|
100
|
+
const picked = spread(all, k);
|
|
101
|
+
return { count: all.length, estimated: false, runs: picked.map((run) => ({ run, weight: all.length / picked.length })) };
|
|
102
|
+
}
|
|
103
|
+
const width = (now - since) / k;
|
|
104
|
+
const slots = await pool(Array.from({ length: k }, (_, i) => since + i * width), 4, async (start) => {
|
|
105
|
+
const created = encodeURIComponent(`${iso(start)}..${iso(start + width - 1000)}`);
|
|
106
|
+
const data = await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100`, token);
|
|
107
|
+
const count = slotCount(data.total_count, data.workflow_runs, width);
|
|
108
|
+
return { count, estimated: data.total_count >= COUNT_CAP, runs: spread(data.workflow_runs, 1).map((run) => ({ run, weight: count })) };
|
|
109
|
+
});
|
|
110
|
+
return { count: slots.reduce((a, s) => a + s.count, 0), estimated: slots.some((s) => s.estimated), runs: slots.flatMap((s) => s.runs) };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* About `max` completed runs since `since`, sampled per workflow and weighted so they add up to every run in the window.
|
|
114
|
+
* Busy repos mix 2-second bot workflows with 20-minute test workflows, so samples are split by each workflow's share of
|
|
115
|
+
* CI time (runs x typical length), and each sample stands only for runs of its own workflow.
|
|
116
|
+
* `estimated` means some period passed GitHub's count cap and its count was estimated from run spacing.
|
|
117
|
+
*/
|
|
118
|
+
export async function listRuns(repo, token, since, max) {
|
|
119
|
+
// Small enough to take every run: exact, and repeats and push-to-push diffs stay visible.
|
|
120
|
+
const all = [];
|
|
121
|
+
const created = encodeURIComponent(`>=${since.toISOString().slice(0, 10)}`);
|
|
122
|
+
for (let page = 1;; page++) {
|
|
123
|
+
const data = await gh(`/repos/${repo}/actions/runs?created=${created}&status=completed&per_page=100&page=${page}`, token);
|
|
124
|
+
if (data.total_count > max)
|
|
125
|
+
break;
|
|
126
|
+
all.push(...data.workflow_runs);
|
|
127
|
+
if (data.workflow_runs.length < 100)
|
|
128
|
+
return { runs: all.map((run) => ({ run, weight: 1 })), total: all.length, estimated: false };
|
|
129
|
+
}
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const ids = [];
|
|
132
|
+
for (let page = 1;; page++) {
|
|
133
|
+
const data = await gh(`/repos/${repo}/actions/workflows?per_page=100&page=${page}`, token);
|
|
134
|
+
ids.push(...data.workflows.map((w) => w.id));
|
|
135
|
+
if (data.workflows.length < 100)
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
const firsts = (await pool(ids, 8, async (id) => ({ id, first: await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100`, token) }))).filter((w) => w.first.total_count > 0);
|
|
139
|
+
// Share of CI time: run count x average run length from the first page.
|
|
140
|
+
const score = (w) => w.first.total_count * (w.first.workflow_runs.reduce((a, r) => a + wallMs(r), 0) / w.first.workflow_runs.length);
|
|
141
|
+
const ks = allocate(firsts.map((w) => ({ count: w.first.total_count, score: score(w) })), max);
|
|
142
|
+
const sampled = await pool(firsts.map((w, i) => ({ w, k: ks[i] })), 4, ({ w, k }) => sampleWorkflow(repo, token, w.id, w.first, since.getTime(), now, k));
|
|
143
|
+
return {
|
|
144
|
+
runs: sampled.flatMap((s) => s.runs),
|
|
145
|
+
total: sampled.reduce((a, s) => a + s.count, 0),
|
|
146
|
+
estimated: sampled.some((s) => s.estimated),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Every attempt's jobs for a run, so re-runs are counted. */
|
|
150
|
+
export async function listJobs(repo, runId, token) {
|
|
151
|
+
const jobs = [];
|
|
152
|
+
for (let page = 1;; page++) {
|
|
153
|
+
const data = await gh(`/repos/${repo}/actions/runs/${runId}/jobs?filter=all&per_page=100&page=${page}`, token);
|
|
154
|
+
jobs.push(...data.jobs);
|
|
155
|
+
if (data.jobs.length < 100)
|
|
156
|
+
return jobs;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Runs `fn` over `items` with at most `limit` in flight. */
|
|
160
|
+
export async function pool(items, limit, fn) {
|
|
161
|
+
const out = new Array(items.length);
|
|
162
|
+
let next = 0;
|
|
163
|
+
const worker = async () => {
|
|
164
|
+
while (next < items.length) {
|
|
165
|
+
const i = next++;
|
|
166
|
+
out[i] = await fn(items[i]);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
170
|
+
return out;
|
|
171
|
+
}
|