vouchington-tooling 0.0.2 → 0.0.4
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/README.md +4 -0
- package/dist/cli/commands/gha-runtime-audit.d.mts +3 -0
- package/dist/cli/commands/gha-runtime-audit.mjs +23 -0
- package/dist/cli/index.d.mts +1 -1
- package/dist/cli/index.mjs +15 -2
- package/dist/cli/parse-gha-runtime-audit.d.mts +13 -0
- package/dist/cli/parse-gha-runtime-audit.mjs +44 -0
- package/dist/cli/parse.d.mts +2 -1
- package/dist/cli/parse.mjs +3 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +7 -0
- package/dist/gha-runtime-audit/audit.d.mts +3 -0
- package/dist/gha-runtime-audit/audit.mjs +106 -0
- package/dist/gha-runtime-audit/index.d.mts +5 -0
- package/dist/gha-runtime-audit/index.mjs +3 -0
- package/dist/gha-runtime-audit/index.test-helpers.d.mts +14 -0
- package/dist/gha-runtime-audit/index.test-helpers.mjs +63 -0
- package/dist/gha-runtime-audit/model.d.mts +60 -0
- package/dist/gha-runtime-audit/model.mjs +83 -0
- package/dist/gha-runtime-audit/results.d.mts +15 -0
- package/dist/gha-runtime-audit/results.mjs +48 -0
- package/dist/gha-runtime-audit/scope.d.mts +29 -0
- package/dist/gha-runtime-audit/scope.mjs +39 -0
- package/dist/index.d.mts +5 -1
- package/dist/index.mjs +3 -1
- package/dist/sql-ast/add-column.d.mts +1 -0
- package/dist/sql-ast/add-column.mjs +22 -0
- package/dist/sql-ast/constraint-do-block.d.mts +11 -0
- package/dist/sql-ast/constraint-do-block.mjs +83 -0
- package/dist/sql-ast/constraint-shared.d.mts +19 -0
- package/dist/sql-ast/constraint-shared.mjs +20 -0
- package/dist/sql-ast/constraint.d.mts +2 -0
- package/dist/sql-ast/constraint.mjs +63 -0
- package/dist/sql-ast/create-table.d.mts +25 -0
- package/dist/sql-ast/create-table.mjs +79 -0
- package/dist/sql-ast/default-function.d.mts +18 -0
- package/dist/sql-ast/default-function.mjs +53 -0
- package/dist/sql-ast/drop-index.d.mts +5 -0
- package/dist/sql-ast/drop-index.mjs +31 -0
- package/dist/sql-ast/implicit-indexes.d.mts +3 -0
- package/dist/sql-ast/implicit-indexes.mjs +66 -0
- package/dist/sql-ast/index-metadata.d.mts +19 -0
- package/dist/sql-ast/index-metadata.mjs +100 -0
- package/dist/sql-ast/index.d.mts +12 -12
- package/dist/sql-ast/index.mjs +8 -79
- package/dist/sql-ast/line-of-offset.d.mts +1 -0
- package/dist/sql-ast/line-of-offset.mjs +12 -0
- package/dist/sql-ast/parser.d.mts +11 -0
- package/dist/sql-ast/parser.mjs +46 -0
- package/dist/sql-ast/unknown-record.d.mts +1 -0
- package/dist/sql-ast/unknown-record.mjs +3 -0
- package/dist/sql-scanner/index.d.mts +7 -0
- package/dist/sql-scanner/index.mjs +172 -0
- package/dist/sql-scanner/literals.d.mts +17 -0
- package/dist/sql-scanner/literals.mjs +158 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ vouchington runner-port-policy
|
|
|
16
16
|
vouchington runner-port-policy --file ./policy.json
|
|
17
17
|
vouchington runner-port-policy --reserved 2200
|
|
18
18
|
vouchington with-host-lock --name expensive-build --timeout-seconds 60 -- make build
|
|
19
|
+
vouchington gha-runtime-audit --pr-workflow CI --push-workflow '/^Main CI \\(.+\\)$/'
|
|
19
20
|
```
|
|
20
21
|
|
|
21
22
|
Host-lock environment:
|
|
@@ -35,4 +36,7 @@ import {
|
|
|
35
36
|
listenOnRunnerUnreservedEphemeralPort,
|
|
36
37
|
runnerPortPolicy,
|
|
37
38
|
} from 'vouchington-tooling/runner-port-policy'
|
|
39
|
+
import { initSqlAst, extractCreateTableMetadata } from 'vouchington-tooling/sql-ast'
|
|
40
|
+
import { splitSqlStatements, stripSqlComments } from 'vouchington-tooling/sql-scanner'
|
|
41
|
+
import { auditCiJobRuntime } from 'vouchington-tooling/gha-runtime-audit'
|
|
38
42
|
```
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type GhApiExecutor } from '../../gha-runtime-audit/index.mts';
|
|
2
|
+
import type { ParsedGhaRuntimeAudit } from '../parse-gha-runtime-audit.mts';
|
|
3
|
+
export declare function runGhaRuntimeAudit(parsed: ParsedGhaRuntimeAudit, execute?: GhApiExecutor, env?: NodeJS.ProcessEnv): Promise<number>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { auditCiJobRuntime } from '../../gha-runtime-audit/index.mjs';
|
|
3
|
+
/* v8 ignore next 10 -- live `gh api` adapter */
|
|
4
|
+
function defaultGhApiExecutor() {
|
|
5
|
+
return async (request) => JSON.parse(execFileSync('gh', ['api', request.endpoint, '--jq', request.jq], {
|
|
6
|
+
encoding: 'utf8',
|
|
7
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
export async function runGhaRuntimeAudit(parsed, execute = defaultGhApiExecutor(), env = process.env) {
|
|
11
|
+
const repository = parsed.repository ?? env['GITHUB_REPOSITORY'];
|
|
12
|
+
if (!repository) {
|
|
13
|
+
process.stderr.write('vouchington: --repository or GITHUB_REPOSITORY is required\n');
|
|
14
|
+
return 2;
|
|
15
|
+
}
|
|
16
|
+
const result = await auditCiJobRuntime(execute, {
|
|
17
|
+
repository,
|
|
18
|
+
workflows: parsed.workflows,
|
|
19
|
+
...(parsed.branch === undefined ? {} : { branch: parsed.branch }),
|
|
20
|
+
});
|
|
21
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
package/dist/cli/index.d.mts
CHANGED
package/dist/cli/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { readPackageVersion } from '../package-version.mjs';
|
|
6
|
+
import { runGhaRuntimeAudit } from './commands/gha-runtime-audit.mjs';
|
|
6
7
|
import { runRunnerPortPolicy } from './commands/runner-port-policy.mjs';
|
|
7
8
|
import { runWithHostLock } from './commands/with-host-lock.mjs';
|
|
8
9
|
import { parseCli } from './parse.mjs';
|
|
@@ -24,6 +25,8 @@ export function runCli(argv = process.argv) {
|
|
|
24
25
|
return runRunnerPortPolicy(parsed);
|
|
25
26
|
case 'with-host-lock':
|
|
26
27
|
return runWithHostLock(parsed.args);
|
|
28
|
+
case 'gha-runtime-audit':
|
|
29
|
+
return runGhaRuntimeAudit(parsed);
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
function readInstalledVersion() {
|
|
@@ -39,7 +42,17 @@ export function isMainModule(metaUrl, argv1) {
|
|
|
39
42
|
return metaUrl === pathToFileURL(resolve(argv1)).href;
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
|
-
/* v8 ignore next
|
|
45
|
+
/* v8 ignore next 8 */
|
|
43
46
|
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
44
|
-
|
|
47
|
+
const result = runCli();
|
|
48
|
+
if (typeof result === 'number')
|
|
49
|
+
process.exitCode = result;
|
|
50
|
+
else {
|
|
51
|
+
result.then((code) => {
|
|
52
|
+
process.exitCode = code;
|
|
53
|
+
}, (error) => {
|
|
54
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
45
58
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RuntimeAuditWorkflowFilter } from '../gha-runtime-audit/scope.mts';
|
|
2
|
+
export type ParsedGhaRuntimeAudit = {
|
|
3
|
+
kind: 'gha-runtime-audit';
|
|
4
|
+
repository?: string;
|
|
5
|
+
branch?: string;
|
|
6
|
+
workflows: RuntimeAuditWorkflowFilter[];
|
|
7
|
+
};
|
|
8
|
+
export declare function parseGhaRuntimeAudit(args: readonly string[]): ParsedGhaRuntimeAudit | {
|
|
9
|
+
kind: 'help';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'error';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { parseWorkflowNameMatch, } from '../gha-runtime-audit/scope.mjs';
|
|
2
|
+
export function parseGhaRuntimeAudit(args) {
|
|
3
|
+
let repository;
|
|
4
|
+
let branch;
|
|
5
|
+
const workflows = [];
|
|
6
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
7
|
+
const flag = args[index];
|
|
8
|
+
if (flag === '--help' || flag === '-h')
|
|
9
|
+
return { kind: 'help' };
|
|
10
|
+
if (flag === '--repository' ||
|
|
11
|
+
flag === '--pr-workflow' ||
|
|
12
|
+
flag === '--push-workflow' ||
|
|
13
|
+
flag === '--branch') {
|
|
14
|
+
const value = args[index + 1];
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return { kind: 'error', message: `${flag} requires a value` };
|
|
17
|
+
index += 1;
|
|
18
|
+
if (flag === '--repository')
|
|
19
|
+
repository = value;
|
|
20
|
+
else if (flag === '--branch')
|
|
21
|
+
branch = value;
|
|
22
|
+
else {
|
|
23
|
+
workflows.push({
|
|
24
|
+
name: parseWorkflowNameMatch(value),
|
|
25
|
+
event: flag === '--pr-workflow' ? 'pull_request' : 'push',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'error', message: `unknown gha-runtime-audit option: ${flag}` };
|
|
31
|
+
}
|
|
32
|
+
if (workflows.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'error',
|
|
35
|
+
message: 'gha-runtime-audit requires --pr-workflow or --push-workflow',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
kind: 'gha-runtime-audit',
|
|
40
|
+
workflows,
|
|
41
|
+
...(repository === undefined ? {} : { repository }),
|
|
42
|
+
...(branch === undefined ? {} : { branch }),
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/cli/parse.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedGhaRuntimeAudit } from './parse-gha-runtime-audit.mts';
|
|
1
2
|
export type ParsedCli = {
|
|
2
3
|
kind: 'help';
|
|
3
4
|
} | {
|
|
@@ -12,5 +13,5 @@ export type ParsedCli = {
|
|
|
12
13
|
} | {
|
|
13
14
|
kind: 'with-host-lock';
|
|
14
15
|
args: string[];
|
|
15
|
-
};
|
|
16
|
+
} | ParsedGhaRuntimeAudit;
|
|
16
17
|
export declare function parseCli(argv: readonly string[]): ParsedCli;
|
package/dist/cli/parse.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseGhaRuntimeAudit } from './parse-gha-runtime-audit.mjs';
|
|
1
2
|
export function parseCli(argv) {
|
|
2
3
|
const args = argv.slice(2);
|
|
3
4
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h')
|
|
@@ -9,6 +10,8 @@ export function parseCli(argv) {
|
|
|
9
10
|
return parseRunnerPortPolicy(rest);
|
|
10
11
|
if (command === 'with-host-lock')
|
|
11
12
|
return { kind: 'with-host-lock', args: rest };
|
|
13
|
+
if (command === 'gha-runtime-audit')
|
|
14
|
+
return parseGhaRuntimeAudit(rest);
|
|
12
15
|
return { kind: 'error', message: `unknown command: ${command}` };
|
|
13
16
|
}
|
|
14
17
|
function parseRunnerPortPolicy(args) {
|
package/dist/cli/usage.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n";
|
|
1
|
+
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n";
|
|
2
2
|
export declare function printUsage(stream?: NodeJS.WritableStream): void;
|
package/dist/cli/usage.mjs
CHANGED
|
@@ -3,6 +3,7 @@ export const USAGE = `Usage: vouchington <command> [options]
|
|
|
3
3
|
Commands:
|
|
4
4
|
runner-port-policy Print or validate a runner port policy
|
|
5
5
|
with-host-lock Run a command under a host-wide lock
|
|
6
|
+
gha-runtime-audit Audit successful GitHub Actions job runtimes
|
|
6
7
|
|
|
7
8
|
Options:
|
|
8
9
|
-h, --help Show this help
|
|
@@ -21,6 +22,12 @@ with-host-lock
|
|
|
21
22
|
[--failure-diagnostics <absolute-script>]
|
|
22
23
|
[--on-acquire-timeout fail|run-unlocked]
|
|
23
24
|
-- <command> [args...]
|
|
25
|
+
|
|
26
|
+
gha-runtime-audit
|
|
27
|
+
[--repository owner/name] Default GITHUB_REPOSITORY
|
|
28
|
+
[--branch main]
|
|
29
|
+
--pr-workflow <name|/regex/> Repeatable
|
|
30
|
+
--push-workflow <name|/regex/> Repeatable
|
|
24
31
|
`;
|
|
25
32
|
export function printUsage(stream = process.stdout) {
|
|
26
33
|
stream.write(USAGE);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { parseArray, parseObject, parseRun, parseSample, parseWorkflow, } from './model.mjs';
|
|
2
|
+
import { buildRuntimeResults } from './results.mjs';
|
|
3
|
+
import { isRunInScope, isSelectedWorkflow, matchingWorkflowFilter, resolveRuntimeAuditOptions, } from './scope.mjs';
|
|
4
|
+
async function discoverWorkflows(execute, options) {
|
|
5
|
+
const selected = [];
|
|
6
|
+
for (let page = 1;; page += 1) {
|
|
7
|
+
const response = parseObject(await execute({
|
|
8
|
+
endpoint: `/repos/${options.repository}/actions/workflows?per_page=100&page=${page}`,
|
|
9
|
+
jq: '{workflows: [.workflows[] | {id, name, state}]}',
|
|
10
|
+
}), 'workflows response must be an object');
|
|
11
|
+
const pageWorkflows = parseArray(response['workflows'], 'workflows must be an array');
|
|
12
|
+
for (const value of pageWorkflows) {
|
|
13
|
+
const workflow = parseWorkflow(value);
|
|
14
|
+
if (isSelectedWorkflow(workflow, options.workflows))
|
|
15
|
+
selected.push(workflow);
|
|
16
|
+
}
|
|
17
|
+
if (pageWorkflows.length < 100)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return selected.toSorted((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
|
|
21
|
+
}
|
|
22
|
+
function runsQuery(filter, branch) {
|
|
23
|
+
if (filter.event === 'push')
|
|
24
|
+
return `event=push&branch=${encodeURIComponent(branch)}`;
|
|
25
|
+
return 'event=pull_request';
|
|
26
|
+
}
|
|
27
|
+
async function recentRuns(execute, workflow, options) {
|
|
28
|
+
const selected = [];
|
|
29
|
+
const filter = matchingWorkflowFilter(workflow.name, options.workflows);
|
|
30
|
+
/* v8 ignore next */
|
|
31
|
+
if (!filter)
|
|
32
|
+
return selected;
|
|
33
|
+
const query = runsQuery(filter, options.branch);
|
|
34
|
+
for (let page = 1;; page += 1) {
|
|
35
|
+
const response = parseObject(await execute({
|
|
36
|
+
endpoint: `/repos/${options.repository}/actions/workflows/${workflow.id}/runs?${query}&status=completed&per_page=100&page=${page}`,
|
|
37
|
+
jq: '{workflow_runs: [.workflow_runs[] | {id, name, event, conclusion, created_at, html_url, head_branch, pull_requests: [(.pull_requests // [])[] | {base: {ref: .base.ref}}]}]}',
|
|
38
|
+
}), 'workflow runs response must be an object');
|
|
39
|
+
const pageRuns = parseArray(response['workflow_runs'], 'workflow_runs must be an array');
|
|
40
|
+
const inScope = [];
|
|
41
|
+
for (const value of pageRuns) {
|
|
42
|
+
const run = parseRun(value);
|
|
43
|
+
if (isRunInScope(run, filter, options.branch))
|
|
44
|
+
inScope.push(run);
|
|
45
|
+
}
|
|
46
|
+
inScope.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.id - a.id);
|
|
47
|
+
for (const run of inScope) {
|
|
48
|
+
if (selected.length === options.recentCompletedRunHorizon)
|
|
49
|
+
break;
|
|
50
|
+
selected.push(run);
|
|
51
|
+
}
|
|
52
|
+
if (selected.length === options.recentCompletedRunHorizon || pageRuns.length < 100)
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
return selected;
|
|
56
|
+
}
|
|
57
|
+
async function collectRunSamples(execute, options, run, samplesByKey) {
|
|
58
|
+
for (let page = 1;; page += 1) {
|
|
59
|
+
const response = parseObject(await execute({
|
|
60
|
+
endpoint: `/repos/${options.repository}/actions/runs/${run.id}/jobs?filter=latest&per_page=100&page=${page}`,
|
|
61
|
+
jq: '{jobs: [.jobs[] | {id, name, started_at, completed_at, conclusion, html_url}]}',
|
|
62
|
+
}), `jobs response for run ${run.id} must be an object`);
|
|
63
|
+
const jobs = parseArray(response['jobs'], `jobs for run ${run.id} must be an array`);
|
|
64
|
+
for (const value of jobs) {
|
|
65
|
+
const parsed = parseSample(value, run);
|
|
66
|
+
if (!parsed)
|
|
67
|
+
continue;
|
|
68
|
+
const key = `${run.name} / ${parsed.name}`;
|
|
69
|
+
const entry = samplesByKey.get(key) ?? {
|
|
70
|
+
workflow: run.name,
|
|
71
|
+
job: parsed.name,
|
|
72
|
+
samples: [],
|
|
73
|
+
};
|
|
74
|
+
if (entry.samples.length < options.sampleLimit)
|
|
75
|
+
entry.samples.push(parsed.sample);
|
|
76
|
+
samplesByKey.set(key, entry);
|
|
77
|
+
}
|
|
78
|
+
if (jobs.length < 100)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function auditCiJobRuntime(execute, options) {
|
|
83
|
+
const resolved = resolveRuntimeAuditOptions(options);
|
|
84
|
+
const samplesByKey = new Map();
|
|
85
|
+
const seenRunIds = new Set();
|
|
86
|
+
for (const workflow of await discoverWorkflows(execute, resolved)) {
|
|
87
|
+
for (const run of await recentRuns(execute, workflow, resolved)) {
|
|
88
|
+
if (seenRunIds.has(run.id))
|
|
89
|
+
continue;
|
|
90
|
+
seenRunIds.add(run.id);
|
|
91
|
+
await collectRunSamples(execute, resolved, run, samplesByKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const results = buildRuntimeResults(samplesByKey, resolved);
|
|
95
|
+
return {
|
|
96
|
+
scope: {
|
|
97
|
+
repository: resolved.repository,
|
|
98
|
+
sampleLimit: resolved.sampleLimit,
|
|
99
|
+
recentCompletedRunHorizon: resolved.recentCompletedRunHorizon,
|
|
100
|
+
medianThresholdSeconds: resolved.medianThresholdSeconds,
|
|
101
|
+
hardCeilingSeconds: resolved.hardCeilingSeconds,
|
|
102
|
+
branch: resolved.branch,
|
|
103
|
+
},
|
|
104
|
+
...results,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { auditCiJobRuntime } from './audit.mts';
|
|
2
|
+
export { violationOrder } from './results.mts';
|
|
3
|
+
export type { GhApiExecutor, GhApiRequest, RuntimeAuditResult, RuntimeJobResult, RuntimeSample, RuntimeViolationReason, } from './model.mts';
|
|
4
|
+
export { parseWorkflowNameMatch } from './scope.mts';
|
|
5
|
+
export type { RuntimeAuditOptions, RuntimeAuditWorkflowFilter } from './scope.mts';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GhApiExecutor } from './model.mts';
|
|
2
|
+
import type { RuntimeAuditOptions } from './scope.mts';
|
|
3
|
+
export type FixtureJob = {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
started_at: string;
|
|
7
|
+
completed_at: string;
|
|
8
|
+
conclusion: 'success' | 'failure';
|
|
9
|
+
html_url: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const exampleAuditOptions: Omit<RuntimeAuditOptions, 'repository'>;
|
|
12
|
+
export declare function makeJob(runId: number, name: string, seconds: number, queueMinutes?: number, conclusion?: FixtureJob['conclusion']): FixtureJob;
|
|
13
|
+
export declare function makeExecutor(runs: unknown[], jobsByRun: Readonly<Record<number, FixtureJob[]>>): GhApiExecutor;
|
|
14
|
+
export declare function makeRun(id: number, name: string, event: 'pull_request' | 'push', branch?: string, conclusion?: string, createdAt?: string, headBranch?: string | null): unknown;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const exampleAuditOptions = {
|
|
2
|
+
workflows: [
|
|
3
|
+
{ name: 'CI', event: 'pull_request' },
|
|
4
|
+
{ name: /^Main CI \(.+\)$/, event: 'push' },
|
|
5
|
+
],
|
|
6
|
+
};
|
|
7
|
+
export function makeJob(runId, name, seconds, queueMinutes = 0, conclusion = 'success') {
|
|
8
|
+
const started = new Date(Date.UTC(2026, 0, runId, 0, queueMinutes));
|
|
9
|
+
return {
|
|
10
|
+
id: runId * 10,
|
|
11
|
+
name,
|
|
12
|
+
started_at: started.toISOString(),
|
|
13
|
+
completed_at: new Date(started.getTime() + seconds * 1000).toISOString(),
|
|
14
|
+
conclusion,
|
|
15
|
+
html_url: `https://github.test/jobs/${runId}`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function makeExecutor(runs, jobsByRun) {
|
|
19
|
+
return async (request) => {
|
|
20
|
+
const { endpoint } = request;
|
|
21
|
+
if (endpoint.includes('/actions/workflows?')) {
|
|
22
|
+
return {
|
|
23
|
+
workflows: endpoint.includes('page=1')
|
|
24
|
+
? [
|
|
25
|
+
{ id: 1, name: 'CI', state: 'active' },
|
|
26
|
+
{ id: 2, name: 'Main CI (web)', state: 'active' },
|
|
27
|
+
{ id: 3, name: 'Other', state: 'active' },
|
|
28
|
+
]
|
|
29
|
+
: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (endpoint.includes('/actions/workflows/1/runs?')) {
|
|
33
|
+
return {
|
|
34
|
+
workflow_runs: endpoint.includes('page=1')
|
|
35
|
+
? runs.filter((run) => run.name === 'CI')
|
|
36
|
+
: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (endpoint.includes('/actions/workflows/2/runs?')) {
|
|
40
|
+
return {
|
|
41
|
+
workflow_runs: endpoint.includes('page=1')
|
|
42
|
+
? runs.filter((run) => run.name === 'Main CI (web)')
|
|
43
|
+
: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const match = endpoint.match(/\/actions\/runs\/(\d+)\/jobs/);
|
|
47
|
+
if (!match)
|
|
48
|
+
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
49
|
+
return { jobs: jobsByRun[Number(match[1])] };
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function makeRun(id, name, event, branch = 'main', conclusion = 'success', createdAt = new Date(Date.UTC(2026, 0, id)).toISOString(), headBranch = branch) {
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name,
|
|
56
|
+
event,
|
|
57
|
+
conclusion,
|
|
58
|
+
created_at: createdAt,
|
|
59
|
+
html_url: `https://github.test/runs/${id}`,
|
|
60
|
+
head_branch: headBranch,
|
|
61
|
+
pull_requests: event === 'pull_request' ? [{ base: { ref: branch } }] : [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface GhApiRequest {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
jq: string;
|
|
4
|
+
}
|
|
5
|
+
export type GhApiExecutor = (request: GhApiRequest) => Promise<unknown>;
|
|
6
|
+
export interface WorkflowRecord {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
state: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RunRecord {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
event: string;
|
|
15
|
+
conclusion: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
url: string;
|
|
18
|
+
headBranch: string | null;
|
|
19
|
+
pullRequestBaseBranches: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface RuntimeSample {
|
|
22
|
+
runId: number;
|
|
23
|
+
runUrl: string;
|
|
24
|
+
jobId: number;
|
|
25
|
+
jobUrl: string;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
completedAt: string;
|
|
28
|
+
durationSeconds: number;
|
|
29
|
+
}
|
|
30
|
+
export type RuntimeViolationReason = 'sample-at-or-above-hard-ceiling' | 'five-sample-median-above-threshold';
|
|
31
|
+
export interface RuntimeJobResult {
|
|
32
|
+
key: string;
|
|
33
|
+
workflow: string;
|
|
34
|
+
job: string;
|
|
35
|
+
sampleCount: number;
|
|
36
|
+
medianSeconds: number | null;
|
|
37
|
+
maximumSeconds: number;
|
|
38
|
+
reasons: RuntimeViolationReason[];
|
|
39
|
+
samples: RuntimeSample[];
|
|
40
|
+
}
|
|
41
|
+
export interface RuntimeAuditResult {
|
|
42
|
+
scope: {
|
|
43
|
+
repository: string;
|
|
44
|
+
sampleLimit: number;
|
|
45
|
+
recentCompletedRunHorizon: number;
|
|
46
|
+
medianThresholdSeconds: number;
|
|
47
|
+
hardCeilingSeconds: number;
|
|
48
|
+
branch: string;
|
|
49
|
+
};
|
|
50
|
+
jobs: RuntimeJobResult[];
|
|
51
|
+
violations: RuntimeJobResult[];
|
|
52
|
+
}
|
|
53
|
+
export declare function parseObject(value: unknown, detail: string): Record<string, unknown>;
|
|
54
|
+
export declare function parseArray(value: unknown, detail: string): unknown[];
|
|
55
|
+
export declare function parseRun(value: unknown): RunRecord;
|
|
56
|
+
export declare function parseWorkflow(value: unknown): WorkflowRecord;
|
|
57
|
+
export declare function parseSample(value: unknown, run: RunRecord): {
|
|
58
|
+
name: string;
|
|
59
|
+
sample: RuntimeSample;
|
|
60
|
+
} | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
function malformed(detail) {
|
|
2
|
+
throw new Error(`Malformed GitHub API data: ${detail}`);
|
|
3
|
+
}
|
|
4
|
+
export function parseObject(value, detail) {
|
|
5
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
6
|
+
malformed(detail);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function parseString(value, detail) {
|
|
10
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
11
|
+
malformed(detail);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function parseNumber(value, detail) {
|
|
15
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)
|
|
16
|
+
malformed(detail);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function parseArray(value, detail) {
|
|
20
|
+
if (!Array.isArray(value))
|
|
21
|
+
malformed(detail);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function parseTimestamp(value, detail) {
|
|
25
|
+
const timestamp = parseString(value, `${detail} must be a non-empty string`);
|
|
26
|
+
if (!Number.isFinite(Date.parse(timestamp)))
|
|
27
|
+
malformed(`${detail} must be an ISO timestamp`);
|
|
28
|
+
return timestamp;
|
|
29
|
+
}
|
|
30
|
+
export function parseRun(value) {
|
|
31
|
+
const run = parseObject(value, 'workflow run must be an object');
|
|
32
|
+
const pullRequests = parseArray(run['pull_requests'], 'workflow run pull_requests must be an array');
|
|
33
|
+
const pullRequestBaseBranches = [];
|
|
34
|
+
for (const [index, entry] of pullRequests.entries()) {
|
|
35
|
+
const pullRequest = parseObject(entry, `pull_requests[${index}] must be an object`);
|
|
36
|
+
const base = parseObject(pullRequest['base'], `pull_requests[${index}].base must be an object`);
|
|
37
|
+
pullRequestBaseBranches.push(parseString(base['ref'], `pull_requests[${index}].base.ref must be a string`));
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
id: parseNumber(run['id'], 'workflow run id must be a positive integer'),
|
|
41
|
+
name: parseString(run['name'], 'workflow run name must be a non-empty string'),
|
|
42
|
+
event: parseString(run['event'], 'workflow run event must be a non-empty string'),
|
|
43
|
+
conclusion: parseString(run['conclusion'], 'workflow run conclusion must be a non-empty string'),
|
|
44
|
+
createdAt: parseTimestamp(run['created_at'], 'workflow run created_at'),
|
|
45
|
+
url: parseString(run['html_url'], 'workflow run html_url must be a non-empty string'),
|
|
46
|
+
headBranch: run['head_branch'] === null
|
|
47
|
+
? null
|
|
48
|
+
: parseString(run['head_branch'], 'workflow run head_branch'),
|
|
49
|
+
pullRequestBaseBranches,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function parseWorkflow(value) {
|
|
53
|
+
const workflow = parseObject(value, 'workflow must be an object');
|
|
54
|
+
return {
|
|
55
|
+
id: parseNumber(workflow['id'], 'workflow id must be a positive integer'),
|
|
56
|
+
name: parseString(workflow['name'], 'workflow name must be a non-empty string'),
|
|
57
|
+
state: parseString(workflow['state'], 'workflow state must be a non-empty string'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function parseSample(value, run) {
|
|
61
|
+
const job = parseObject(value, `job in run ${run.id} must be an object`);
|
|
62
|
+
const conclusion = parseString(job['conclusion'], `job in run ${run.id} conclusion must be a string`);
|
|
63
|
+
if (conclusion !== 'success')
|
|
64
|
+
return null;
|
|
65
|
+
const startedAt = parseTimestamp(job['started_at'], `job in run ${run.id} started_at`);
|
|
66
|
+
const completedAt = parseTimestamp(job['completed_at'], `job in run ${run.id} completed_at`);
|
|
67
|
+
const durationSeconds = (Date.parse(completedAt) - Date.parse(startedAt)) / 1000;
|
|
68
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
|
|
69
|
+
malformed(`job in run ${run.id} has an invalid execution interval`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: parseString(job['name'], `job in run ${run.id} name must be a non-empty string`),
|
|
73
|
+
sample: {
|
|
74
|
+
runId: run.id,
|
|
75
|
+
runUrl: run.url,
|
|
76
|
+
jobId: parseNumber(job['id'], `job in run ${run.id} id must be a positive integer`),
|
|
77
|
+
jobUrl: parseString(job['html_url'], `job in run ${run.id} html_url must be a string`),
|
|
78
|
+
startedAt,
|
|
79
|
+
completedAt,
|
|
80
|
+
durationSeconds,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RuntimeJobResult, RuntimeSample } from './model.mts';
|
|
2
|
+
export interface RuntimeSamplesByJob {
|
|
3
|
+
workflow: string;
|
|
4
|
+
job: string;
|
|
5
|
+
samples: RuntimeSample[];
|
|
6
|
+
}
|
|
7
|
+
export type RuntimeThresholds = {
|
|
8
|
+
medianThresholdSeconds: number;
|
|
9
|
+
hardCeilingSeconds: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function violationOrder(a: RuntimeJobResult, b: RuntimeJobResult): number;
|
|
12
|
+
export declare function buildRuntimeResults(samplesByKey: ReadonlyMap<string, RuntimeSamplesByJob>, thresholds: RuntimeThresholds): {
|
|
13
|
+
jobs: RuntimeJobResult[];
|
|
14
|
+
violations: RuntimeJobResult[];
|
|
15
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function summarize(entry, thresholds) {
|
|
2
|
+
const durations = [];
|
|
3
|
+
for (const sample of entry.samples)
|
|
4
|
+
durations.push(sample.durationSeconds);
|
|
5
|
+
durations.sort((a, b) => a - b);
|
|
6
|
+
const medianSeconds = durations.length === 5 ? durations[2] : null;
|
|
7
|
+
const reasons = [];
|
|
8
|
+
if (durations.some((duration) => duration >= thresholds.hardCeilingSeconds)) {
|
|
9
|
+
reasons.push('sample-at-or-above-hard-ceiling');
|
|
10
|
+
}
|
|
11
|
+
if (medianSeconds !== null && medianSeconds > thresholds.medianThresholdSeconds) {
|
|
12
|
+
reasons.push('five-sample-median-above-threshold');
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
key: `${entry.workflow} / ${entry.job}`,
|
|
16
|
+
workflow: entry.workflow,
|
|
17
|
+
job: entry.job,
|
|
18
|
+
sampleCount: entry.samples.length,
|
|
19
|
+
medianSeconds,
|
|
20
|
+
maximumSeconds: Math.max(...durations),
|
|
21
|
+
reasons,
|
|
22
|
+
samples: entry.samples,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function violationOrder(a, b) {
|
|
26
|
+
const aHard = a.reasons.includes('sample-at-or-above-hard-ceiling');
|
|
27
|
+
const bHard = b.reasons.includes('sample-at-or-above-hard-ceiling');
|
|
28
|
+
if (aHard && !bHard)
|
|
29
|
+
return -1;
|
|
30
|
+
if (!aHard && bHard)
|
|
31
|
+
return 1;
|
|
32
|
+
const aScore = aHard ? a.maximumSeconds : a.medianSeconds;
|
|
33
|
+
const bScore = bHard ? b.maximumSeconds : b.medianSeconds;
|
|
34
|
+
return bScore - aScore || a.key.localeCompare(b.key);
|
|
35
|
+
}
|
|
36
|
+
export function buildRuntimeResults(samplesByKey, thresholds) {
|
|
37
|
+
const jobs = [];
|
|
38
|
+
for (const entry of samplesByKey.values())
|
|
39
|
+
jobs.push(summarize(entry, thresholds));
|
|
40
|
+
jobs.sort((a, b) => a.key.localeCompare(b.key));
|
|
41
|
+
const violations = [];
|
|
42
|
+
for (const job of jobs) {
|
|
43
|
+
if (job.reasons.length > 0)
|
|
44
|
+
violations.push(job);
|
|
45
|
+
}
|
|
46
|
+
violations.sort(violationOrder);
|
|
47
|
+
return { jobs, violations };
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RunRecord, WorkflowRecord } from './model.mts';
|
|
2
|
+
export type WorkflowNameMatch = string | RegExp;
|
|
3
|
+
export type RuntimeAuditWorkflowFilter = {
|
|
4
|
+
name: WorkflowNameMatch;
|
|
5
|
+
event: 'pull_request' | 'push';
|
|
6
|
+
};
|
|
7
|
+
export type RuntimeAuditOptions = {
|
|
8
|
+
repository: string;
|
|
9
|
+
workflows: RuntimeAuditWorkflowFilter[];
|
|
10
|
+
branch?: string;
|
|
11
|
+
sampleLimit?: number;
|
|
12
|
+
recentCompletedRunHorizon?: number;
|
|
13
|
+
medianThresholdSeconds?: number;
|
|
14
|
+
hardCeilingSeconds?: number;
|
|
15
|
+
};
|
|
16
|
+
export type ResolvedRuntimeAuditOptions = {
|
|
17
|
+
repository: string;
|
|
18
|
+
workflows: RuntimeAuditWorkflowFilter[];
|
|
19
|
+
branch: string;
|
|
20
|
+
sampleLimit: number;
|
|
21
|
+
recentCompletedRunHorizon: number;
|
|
22
|
+
medianThresholdSeconds: number;
|
|
23
|
+
hardCeilingSeconds: number;
|
|
24
|
+
};
|
|
25
|
+
export declare function parseWorkflowNameMatch(value: string): WorkflowNameMatch;
|
|
26
|
+
export declare function resolveRuntimeAuditOptions(options: RuntimeAuditOptions): ResolvedRuntimeAuditOptions;
|
|
27
|
+
export declare function matchingWorkflowFilter(name: string, filters: readonly RuntimeAuditWorkflowFilter[]): RuntimeAuditWorkflowFilter | undefined;
|
|
28
|
+
export declare function isSelectedWorkflow(workflow: WorkflowRecord, filters: readonly RuntimeAuditWorkflowFilter[]): boolean;
|
|
29
|
+
export declare function isRunInScope(run: RunRecord, filter: RuntimeAuditWorkflowFilter, branch: string): boolean;
|