@ultimat3/cli 6.0.0 → 7.0.0
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/CLAUDE.md +50 -4
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/static-report.ts +219 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
package/src/ci-runs.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Which workflow runs `x ci` is about, and the three `gh run` calls that answer it. Selection
|
|
2
|
+
// lives here rather than in the command so "the latest run of each workflow on this branch" is one
|
|
3
|
+
// testable function over plain rows instead of a shape only a subprocess can produce.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/schema';
|
|
6
|
+
import type { GhHost } from './gh';
|
|
7
|
+
import { ghJson, runGh } from './gh';
|
|
8
|
+
import type { GhRepo } from './gh-target';
|
|
9
|
+
|
|
10
|
+
/** The fields both `gh run list` and `gh run view` accept, named once so the two agree. */
|
|
11
|
+
export const RUN_FIELDS =
|
|
12
|
+
'databaseId,status,conclusion,headBranch,displayTitle,workflowName,url,createdAt';
|
|
13
|
+
|
|
14
|
+
const RUN_ROW = t.object({
|
|
15
|
+
databaseId: t.number,
|
|
16
|
+
status: t.string,
|
|
17
|
+
conclusion: t.nullable(t.string),
|
|
18
|
+
headBranch: t.string,
|
|
19
|
+
displayTitle: t.string,
|
|
20
|
+
workflowName: t.string,
|
|
21
|
+
url: t.string,
|
|
22
|
+
createdAt: t.string,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const STEP = t.object({ name: t.string, conclusion: t.nullable(t.string), number: t.number });
|
|
26
|
+
|
|
27
|
+
const JOB = t.object({
|
|
28
|
+
name: t.string,
|
|
29
|
+
status: t.string,
|
|
30
|
+
conclusion: t.nullable(t.string),
|
|
31
|
+
url: t.string,
|
|
32
|
+
steps: t.array(STEP),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const RUN_LIST = t.array(RUN_ROW);
|
|
36
|
+
const RUN_VIEW = RUN_ROW.extend({ jobs: t.array(JOB) });
|
|
37
|
+
|
|
38
|
+
export interface CiStep {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly conclusion: string | null;
|
|
41
|
+
readonly number: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CiJob {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly status: string;
|
|
47
|
+
readonly conclusion: string | null;
|
|
48
|
+
readonly url: string;
|
|
49
|
+
readonly steps: readonly CiStep[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CiRun {
|
|
53
|
+
readonly id: number;
|
|
54
|
+
readonly status: string;
|
|
55
|
+
readonly conclusion: string | null;
|
|
56
|
+
readonly branch: string;
|
|
57
|
+
readonly title: string;
|
|
58
|
+
readonly workflow: string;
|
|
59
|
+
readonly url: string;
|
|
60
|
+
readonly createdAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The conclusions that mean "this run is not green". `cancelled` and `timed_out` are in it because
|
|
65
|
+
* a cancelled run has told the reader nothing about their change, and a command that reported it
|
|
66
|
+
* as passing would be a green verdict over an unanswered question. `skipped` is not: a workflow
|
|
67
|
+
* whose conditions did not match was never asked.
|
|
68
|
+
*/
|
|
69
|
+
export const FAILED_CONCLUSIONS: readonly string[] = [
|
|
70
|
+
'failure',
|
|
71
|
+
'cancelled',
|
|
72
|
+
'timed_out',
|
|
73
|
+
'startup_failure',
|
|
74
|
+
'action_required',
|
|
75
|
+
'stale',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const isFailed = (run: CiRun): boolean =>
|
|
79
|
+
run.conclusion !== null && FAILED_CONCLUSIONS.includes(run.conclusion);
|
|
80
|
+
|
|
81
|
+
export const isRunning = (run: CiRun): boolean => run.status !== 'completed';
|
|
82
|
+
|
|
83
|
+
interface RunRow {
|
|
84
|
+
readonly databaseId: number;
|
|
85
|
+
readonly status: string;
|
|
86
|
+
readonly conclusion: string | null;
|
|
87
|
+
readonly headBranch: string;
|
|
88
|
+
readonly displayTitle: string;
|
|
89
|
+
readonly workflowName: string;
|
|
90
|
+
readonly url: string;
|
|
91
|
+
readonly createdAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const runOf = (row: RunRow): CiRun => ({
|
|
95
|
+
id: row.databaseId,
|
|
96
|
+
status: row.status,
|
|
97
|
+
conclusion: row.conclusion,
|
|
98
|
+
branch: row.headBranch,
|
|
99
|
+
title: row.displayTitle,
|
|
100
|
+
workflow: row.workflowName,
|
|
101
|
+
url: row.url,
|
|
102
|
+
createdAt: row.createdAt,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The newest run of EACH workflow, which is the honest answer to "is CI green on this branch".
|
|
107
|
+
* Taking the newest run overall reports whichever workflow happened to finish last — this repo
|
|
108
|
+
* runs `ci` and `deploy-social-demo` off the same push, so half the time that is a verdict about
|
|
109
|
+
* a workflow the caller was not asking about.
|
|
110
|
+
*/
|
|
111
|
+
export function latestPerWorkflow(runs: readonly CiRun[]): readonly CiRun[] {
|
|
112
|
+
const newest = new Map<string, CiRun>();
|
|
113
|
+
for (const run of runs) {
|
|
114
|
+
const held = newest.get(run.workflow);
|
|
115
|
+
if (held === undefined || run.createdAt > held.createdAt) newest.set(run.workflow, run);
|
|
116
|
+
}
|
|
117
|
+
return [...newest.values()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function listRuns(
|
|
121
|
+
host: GhHost,
|
|
122
|
+
repo: GhRepo,
|
|
123
|
+
branch: string,
|
|
124
|
+
limit: number,
|
|
125
|
+
): Promise<readonly CiRun[]> {
|
|
126
|
+
const rows = await ghJson(
|
|
127
|
+
host,
|
|
128
|
+
[
|
|
129
|
+
'run',
|
|
130
|
+
'list',
|
|
131
|
+
'--repo',
|
|
132
|
+
repo.slug,
|
|
133
|
+
'--branch',
|
|
134
|
+
branch,
|
|
135
|
+
'--limit',
|
|
136
|
+
String(limit),
|
|
137
|
+
'--json',
|
|
138
|
+
RUN_FIELDS,
|
|
139
|
+
],
|
|
140
|
+
RUN_LIST,
|
|
141
|
+
{
|
|
142
|
+
label: `gh run list --branch ${branch}`,
|
|
143
|
+
fix: `x ci --branch ${branch} --repo ${repo.slug} --json`,
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
return rows.map(runOf);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One run, with its jobs — `gh run view --json` carries both, so this is one round trip. */
|
|
150
|
+
export async function viewRun(
|
|
151
|
+
host: GhHost,
|
|
152
|
+
repo: GhRepo,
|
|
153
|
+
id: number,
|
|
154
|
+
): Promise<{ readonly run: CiRun; readonly jobs: readonly CiJob[] }> {
|
|
155
|
+
const viewed = await ghJson(
|
|
156
|
+
host,
|
|
157
|
+
['run', 'view', String(id), '--repo', repo.slug, '--json', `${RUN_FIELDS},jobs`],
|
|
158
|
+
RUN_VIEW,
|
|
159
|
+
{ label: `gh run view ${id}`, fix: `x ci --run ${id} --repo ${repo.slug} --json` },
|
|
160
|
+
);
|
|
161
|
+
return { run: runOf(viewed), jobs: viewed.jobs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The failed steps' log, and only those. `--log` is the whole run — setup, caches, every green
|
|
166
|
+
* step — where `--log-failed` is the part a triage starts from, which is the difference between
|
|
167
|
+
* one command and three.
|
|
168
|
+
*/
|
|
169
|
+
export async function failedLog(host: GhHost, repo: GhRepo, id: number): Promise<string> {
|
|
170
|
+
const result = await runGh(
|
|
171
|
+
host,
|
|
172
|
+
['run', 'view', String(id), '--repo', repo.slug, '--log-failed'],
|
|
173
|
+
{
|
|
174
|
+
label: `gh run view ${id} --log-failed`,
|
|
175
|
+
fix: `gh run view ${id} --repo ${repo.slug} --log # the whole log, when the failed steps carry none`,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return result.stdout;
|
|
179
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `x affected` — the workspaces a diff forces a re-test of, closed transitively over the workspace
|
|
2
|
+
// graph. `x verify` runs everything, which is right for a gate and wrong for the loop an agent
|
|
3
|
+
// iterates in; without this command that agent invents its own scoping, and an invented one that
|
|
4
|
+
// misses a transitive dependent is a green checkmark on a broken repo.
|
|
5
|
+
//
|
|
6
|
+
// CLI wiring only. What a diff touches is `affected.ts`, what the workspaces are is
|
|
7
|
+
// `workspace-graph.ts` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
|
|
8
|
+
|
|
9
|
+
import { affectedScope, DEFAULT_BASE } from './affected';
|
|
10
|
+
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { msg } from './messages';
|
|
12
|
+
import type { CommandResult, JsonValue } from './output';
|
|
13
|
+
import { flagBool } from './parse';
|
|
14
|
+
import { renderTable } from './table';
|
|
15
|
+
|
|
16
|
+
const HEADER = ['workspace', 'dir'] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every catalog row the affected surface renders — this command's four and the one `x test
|
|
20
|
+
* --affected` prints when nothing is affected. One list, because the two commands are one surface
|
|
21
|
+
* and a per-file list would leave whichever half nobody thought about unchecked.
|
|
22
|
+
*
|
|
23
|
+
* The rule it exists for: `msg()` answers `⟦key⟧` for a key the catalog lacks, which is loud in the
|
|
24
|
+
* terminal and completely silent to a build, so a command can ship a summary no locale renders.
|
|
25
|
+
* `cmd-affected.test.ts` holds `messages.ts` to this list.
|
|
26
|
+
*/
|
|
27
|
+
export const AFFECTED_MESSAGE_KEYS = [
|
|
28
|
+
'cli.affected.count',
|
|
29
|
+
'cli.affected.none',
|
|
30
|
+
'cli.affected.rootWide',
|
|
31
|
+
'cli.affected.dirty',
|
|
32
|
+
'cli.test.affected.none',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export const affectedCommand: CliCommand = {
|
|
36
|
+
spec: {
|
|
37
|
+
name: 'affected',
|
|
38
|
+
summary: 'the workspaces a diff touches, and every workspace that depends on one of them',
|
|
39
|
+
usage: 'x affected [--base <ref>] [--dirty] [--paths] [--json]',
|
|
40
|
+
flags: [
|
|
41
|
+
{
|
|
42
|
+
name: 'base',
|
|
43
|
+
type: 'string',
|
|
44
|
+
summary: `git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'dirty',
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
summary: 'also count uncommitted work — every agent sharing this checkout, not only yours',
|
|
50
|
+
},
|
|
51
|
+
{ name: 'paths', type: 'boolean', summary: 'print bare directories instead of a table' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
55
|
+
// The one resolver `x test --affected` narrows with, so this command reports exactly what that
|
|
56
|
+
// one runs. It reads `--base` before git is spawned (a malformed ref must not cost a
|
|
57
|
+
// subprocess) and takes the diff at the CHECKOUT root, which is what git prints paths against.
|
|
58
|
+
const { selection, root, plan } = await affectedScope({
|
|
59
|
+
runner: ctx.runner,
|
|
60
|
+
cwd: ctx.cwd,
|
|
61
|
+
args: ctx.args,
|
|
62
|
+
command: 'affected',
|
|
63
|
+
});
|
|
64
|
+
const params = { base: selection.base, changed: plan.changed.length };
|
|
65
|
+
const table = renderTable(
|
|
66
|
+
HEADER,
|
|
67
|
+
plan.workspaces.map((workspace) => [workspace.name, workspace.dir]),
|
|
68
|
+
).map((line) => ` ${line}`);
|
|
69
|
+
const data: JsonValue = {
|
|
70
|
+
base: selection.base,
|
|
71
|
+
dirty: selection.dirty,
|
|
72
|
+
root,
|
|
73
|
+
changed: [...plan.changed],
|
|
74
|
+
ignored: [...plan.ignored],
|
|
75
|
+
rootWide: [...plan.rootWide],
|
|
76
|
+
workspaces: plan.workspaces.map((workspace) => ({
|
|
77
|
+
name: workspace.name,
|
|
78
|
+
dir: workspace.dir,
|
|
79
|
+
})),
|
|
80
|
+
paths: plan.workspaces.map((workspace) => workspace.dir),
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
// An empty answer is a fact, not a failure: a `.md`-only diff genuinely re-checks nothing,
|
|
84
|
+
// and reporting it red would fail a build for editing a doc. The count is in the summary and
|
|
85
|
+
// `data.changed`/`data.ignored` say what was looked at, so "green because nothing is
|
|
86
|
+
// affected" is never mistaken for "green because everything passed".
|
|
87
|
+
ok: true,
|
|
88
|
+
command: 'affected',
|
|
89
|
+
summary:
|
|
90
|
+
plan.workspaces.length === 0
|
|
91
|
+
? msg('cli.affected.none', params)
|
|
92
|
+
: msg('cli.affected.count', { ...params, count: plan.workspaces.length }),
|
|
93
|
+
lines: [
|
|
94
|
+
...(selection.dirty ? [msg('cli.affected.dirty')] : []),
|
|
95
|
+
...(plan.rootWide.length === 0
|
|
96
|
+
? []
|
|
97
|
+
: [msg('cli.affected.rootWide', { files: plan.rootWide.join(', ') })]),
|
|
98
|
+
...(plan.workspaces.length === 0
|
|
99
|
+
? []
|
|
100
|
+
: // `--paths` changes only what the human sees; `--json` carries both projections on
|
|
101
|
+
// every run, so the two renderers can never state different sets.
|
|
102
|
+
flagBool(ctx.args, 'paths')
|
|
103
|
+
? plan.workspaces.map((workspace) => workspace.dir)
|
|
104
|
+
: table),
|
|
105
|
+
],
|
|
106
|
+
data,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
package/src/cmd-build.ts
CHANGED
|
@@ -14,6 +14,13 @@ import { execOutput } from './exec';
|
|
|
14
14
|
import { msg } from './messages';
|
|
15
15
|
import type { CommandResult } from './output';
|
|
16
16
|
import { flagString } from './parse';
|
|
17
|
+
import type { StaticReport } from './static-report';
|
|
18
|
+
import {
|
|
19
|
+
readStaticReport,
|
|
20
|
+
removeStaticReport,
|
|
21
|
+
renderStaticReport,
|
|
22
|
+
staticReportData,
|
|
23
|
+
} from './static-report';
|
|
17
24
|
|
|
18
25
|
export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
|
|
19
26
|
|
|
@@ -111,14 +118,19 @@ export function preflightResult(verify: CommandResult): CommandResult {
|
|
|
111
118
|
* `✗ built docker`; and the builder's own logs went only into `lines`, which is declared human-only
|
|
112
119
|
* and which `renderJson` drops — so CI, which runs `--json`, got the exit code and nothing to act
|
|
113
120
|
* on. The output now rides in `data` and `lines` renders that same string.
|
|
121
|
+
*
|
|
122
|
+
* `report` is the static target's inventory, and BOTH renderers get it: `--json` is the house rule,
|
|
123
|
+
* but an agent reading terminal output is the primary developer here, so a silent human path is the
|
|
124
|
+
* same defect in a different costume. `lines` still carries nothing `data` does not (#242).
|
|
114
125
|
*/
|
|
115
126
|
export function buildResult(input: {
|
|
116
127
|
readonly target: BuildTarget;
|
|
117
128
|
readonly artifact: string;
|
|
118
129
|
readonly command: readonly string[];
|
|
119
130
|
readonly result: ExecResult;
|
|
131
|
+
readonly report?: StaticReport;
|
|
120
132
|
}): CommandResult {
|
|
121
|
-
const { result, target } = input;
|
|
133
|
+
const { report, result, target } = input;
|
|
122
134
|
const output = result.ok ? '' : execOutput(result);
|
|
123
135
|
return {
|
|
124
136
|
ok: result.ok,
|
|
@@ -139,8 +151,13 @@ export function buildResult(input: {
|
|
|
139
151
|
artifact: input.artifact,
|
|
140
152
|
durationMs: result.durationMs,
|
|
141
153
|
...(result.ok ? {} : { output }),
|
|
154
|
+
...staticReportData(report),
|
|
142
155
|
},
|
|
143
|
-
lines: result.ok
|
|
156
|
+
lines: result.ok
|
|
157
|
+
? report === undefined
|
|
158
|
+
? []
|
|
159
|
+
: renderStaticReport(report)
|
|
160
|
+
: output.split('\n'),
|
|
144
161
|
};
|
|
145
162
|
}
|
|
146
163
|
|
|
@@ -178,11 +195,20 @@ export const buildCommand: CliCommand = {
|
|
|
178
195
|
flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
|
|
179
196
|
const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
|
|
180
197
|
const command = argsFor(target, { root, tag, out });
|
|
198
|
+
// Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
|
|
199
|
+
// with the last one's: a stale emitted list is worse than none, because it reads as this run's.
|
|
200
|
+
if (target === 'static') await removeStaticReport(root);
|
|
201
|
+
const result = await ctx.runner(command, { cwd: root });
|
|
202
|
+
// `prerenderSite` writes it; an app whose `apps/web/prerender.ts` does not call that writes no
|
|
203
|
+
// `.x/build-stats.json` either, and `x verify`'s `budgets` step already reds that app with
|
|
204
|
+
// `X_BUDGET_UNMEASURED` — so the absence needs no second code here.
|
|
205
|
+
const report = target === 'static' && result.ok ? await readStaticReport(root) : undefined;
|
|
181
206
|
return buildResult({
|
|
182
207
|
target,
|
|
183
208
|
artifact: target === 'docker' ? tag : out,
|
|
184
209
|
command,
|
|
185
|
-
result
|
|
210
|
+
result,
|
|
211
|
+
...(report === undefined ? {} : { report }),
|
|
186
212
|
});
|
|
187
213
|
},
|
|
188
214
|
};
|
package/src/cmd-ci.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// `x ci` — one command instead of three. `gh run view` prints a tree of ticks and one cross, and
|
|
2
|
+
// the error is inside a per-job log that is mostly setup noise; for a `verify` job that log tail IS
|
|
3
|
+
// the findings block, with its `X_*` codes and executable `fix:` lines already in it. So this
|
|
4
|
+
// command fetches the runs, opens only the failed steps' log, and hands back the findings the gate
|
|
5
|
+
// already wrote — in the same three-line shape every other Ultimate error is printed in.
|
|
6
|
+
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { CiLogLine } from './ci-log';
|
|
9
|
+
import { findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
|
|
10
|
+
import type { CiJob, CiRun } from './ci-runs';
|
|
11
|
+
import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
|
|
12
|
+
import type { CliCommand, CommandContext } from './command';
|
|
13
|
+
import { parseIntFlag } from './flag-number';
|
|
14
|
+
import type { GhRepo } from './gh-target';
|
|
15
|
+
import { currentBranch, resolveRepo } from './gh-target';
|
|
16
|
+
import { msg } from './messages';
|
|
17
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
18
|
+
import { flagBool, flagString } from './parse';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every catalog key this command renders, declared — `msg()` answers `⟦key⟧` for a key nobody
|
|
22
|
+
* added, which is loud in a terminal and SILENT to a build. `cmd-ci.test.ts` holds this list
|
|
23
|
+
* against the catalog, so a missing string is a failing test rather than a rendered artefact.
|
|
24
|
+
*/
|
|
25
|
+
export const CI_MESSAGE_KEYS = [
|
|
26
|
+
'cli.ci.failed',
|
|
27
|
+
'cli.ci.green',
|
|
28
|
+
'cli.ci.running',
|
|
29
|
+
'cli.ci.run',
|
|
30
|
+
'cli.ci.job',
|
|
31
|
+
'cli.ci.jobs.other',
|
|
32
|
+
'cli.ci.tail',
|
|
33
|
+
'cli.ci.pending',
|
|
34
|
+
'cli.ci.logs.empty',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
|
|
38
|
+
export const TAIL_LINES = 40;
|
|
39
|
+
|
|
40
|
+
/** How far back a branch's run history is read before "the latest run of each workflow". */
|
|
41
|
+
export const RUN_LOOKBACK = 20;
|
|
42
|
+
|
|
43
|
+
/** No run to triage. The remedy is a branch that has one, or the id of the run in question. */
|
|
44
|
+
export class CiRunNotFoundError extends UltimateError {
|
|
45
|
+
constructor(input: { branch: string; repo: string }) {
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_CI_RUN_NOT_FOUND',
|
|
48
|
+
cause: `no workflow run on ${input.repo} for branch "${input.branch}"`,
|
|
49
|
+
fix: `x ci --branch main --repo ${input.repo} --json`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 32484583944 --json' };
|
|
55
|
+
const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
|
|
56
|
+
|
|
57
|
+
export const ciCommand: CliCommand = {
|
|
58
|
+
spec: {
|
|
59
|
+
name: 'ci',
|
|
60
|
+
summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
|
|
61
|
+
usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
|
|
62
|
+
flags: [
|
|
63
|
+
{ name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
|
|
64
|
+
{ name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
|
|
65
|
+
{ name: 'run', type: 'string', summary: 'one run id, instead of this branch latest' },
|
|
66
|
+
{
|
|
67
|
+
name: 'tail',
|
|
68
|
+
type: 'string',
|
|
69
|
+
summary: `log lines kept per failed job (default ${TAIL_LINES})`,
|
|
70
|
+
},
|
|
71
|
+
{ name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
75
|
+
const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
|
|
76
|
+
const rawRun = flagString(ctx.args, 'run');
|
|
77
|
+
const rawTail = flagString(ctx.args, 'tail');
|
|
78
|
+
const limit = flagBool(ctx.args, 'full')
|
|
79
|
+
? Number.POSITIVE_INFINITY
|
|
80
|
+
: rawTail === undefined
|
|
81
|
+
? TAIL_LINES
|
|
82
|
+
: parseIntFlag(rawTail, TAIL_FLAG);
|
|
83
|
+
if (rawRun !== undefined) {
|
|
84
|
+
const viewed = await viewRun(ctx, repo, parseIntFlag(rawRun, RUN_FLAG));
|
|
85
|
+
return report(repo, viewed.run.branch, [await inspect(ctx, repo, viewed, limit)]);
|
|
86
|
+
}
|
|
87
|
+
const branch = flagString(ctx.args, 'branch') ?? (await currentBranch(ctx));
|
|
88
|
+
const runs = latestPerWorkflow(await listRuns(ctx, repo, branch, RUN_LOOKBACK));
|
|
89
|
+
if (runs.length === 0) throw new CiRunNotFoundError({ branch, repo: repo.slug });
|
|
90
|
+
const inspected: RunReport[] = [];
|
|
91
|
+
for (const run of runs) {
|
|
92
|
+
// Only a failed run is opened. A green run's jobs are 35 rows saying `success`, and fetching
|
|
93
|
+
// them would turn the fast answer ("CI is green") into the slow one.
|
|
94
|
+
inspected.push(
|
|
95
|
+
isFailed(run) ? await inspect(ctx, repo, await viewRun(ctx, repo, run.id), limit) : { run },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return report(repo, branch, inspected);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface JobFailure {
|
|
103
|
+
readonly job: string;
|
|
104
|
+
readonly conclusion: string;
|
|
105
|
+
readonly url: string;
|
|
106
|
+
readonly failedSteps: readonly string[];
|
|
107
|
+
readonly tail: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RunReport {
|
|
111
|
+
readonly run: CiRun;
|
|
112
|
+
readonly jobs?: readonly CiJob[];
|
|
113
|
+
readonly failures?: readonly JobFailure[];
|
|
114
|
+
readonly findings?: readonly Finding[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stepFailed = (conclusion: string | null): boolean =>
|
|
118
|
+
conclusion !== null && conclusion !== 'success' && conclusion !== 'skipped';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One failed run, opened. The findings are attributed to the job whose lines produced them, and
|
|
122
|
+
* a job the log attributes nothing to falls back to the whole log — a format change on GitHub's
|
|
123
|
+
* side must cost the attribution, never the finding.
|
|
124
|
+
*/
|
|
125
|
+
async function inspect(
|
|
126
|
+
ctx: CommandContext,
|
|
127
|
+
repo: GhRepo,
|
|
128
|
+
viewed: { readonly run: CiRun; readonly jobs: readonly CiJob[] },
|
|
129
|
+
limit: number,
|
|
130
|
+
): Promise<RunReport> {
|
|
131
|
+
const { run, jobs } = viewed;
|
|
132
|
+
if (!isFailed(run)) return { run, jobs };
|
|
133
|
+
const lines = parseLogLines(await failedLog(ctx, repo, run.id));
|
|
134
|
+
const failed = jobs.filter((job) => stepFailed(job.conclusion));
|
|
135
|
+
// A run can fail with no failed JOB — a startup failure, a cancelled matrix — and the log is
|
|
136
|
+
// then the only thing that knows which name to file it under.
|
|
137
|
+
const names = failed.length > 0 ? failed.map((job) => job.name) : jobsInLog(lines);
|
|
138
|
+
const failures: JobFailure[] = [];
|
|
139
|
+
const findings: Finding[] = [];
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const own = lines.filter((line) => line.job === name);
|
|
142
|
+
const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
|
|
143
|
+
const job = failed.find((candidate) => candidate.name === name);
|
|
144
|
+
for (const finding of findingsFrom(pool)) {
|
|
145
|
+
findings.push(finding.at === undefined ? { ...finding, at: name } : finding);
|
|
146
|
+
}
|
|
147
|
+
failures.push({
|
|
148
|
+
job: name,
|
|
149
|
+
conclusion: job?.conclusion ?? conclusionOf(run),
|
|
150
|
+
url: job?.url ?? run.url,
|
|
151
|
+
failedSteps: (job?.steps ?? [])
|
|
152
|
+
.filter((step) => stepFailed(step.conclusion))
|
|
153
|
+
.map((step) => step.name),
|
|
154
|
+
tail: tailOf(lines, name, limit),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { run, jobs, failures, findings: dedupe(findings) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The same block can be reached twice — once per job when the log attributes nothing, once per
|
|
162
|
+
* attempt when a run was re-run — and two copies of one finding read as two problems.
|
|
163
|
+
*/
|
|
164
|
+
function dedupe(findings: readonly Finding[]): readonly Finding[] {
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
return findings.filter((finding) => {
|
|
167
|
+
const key = `${finding.code} ${finding.cause}`;
|
|
168
|
+
if (seen.has(key)) return false;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const conclusionOf = (run: CiRun): string => run.conclusion ?? msg('cli.ci.pending');
|
|
175
|
+
|
|
176
|
+
function report(repo: GhRepo, branch: string, reports: readonly RunReport[]): CommandResult {
|
|
177
|
+
const failed = reports.filter((entry) => isFailed(entry.run));
|
|
178
|
+
const running = reports.filter((entry) => isRunning(entry.run));
|
|
179
|
+
const findings = dedupe(reports.flatMap((entry) => entry.findings ?? []));
|
|
180
|
+
return {
|
|
181
|
+
ok: failed.length === 0,
|
|
182
|
+
command: 'ci',
|
|
183
|
+
summary:
|
|
184
|
+
failed.length > 0
|
|
185
|
+
? msg('cli.ci.failed', {
|
|
186
|
+
failed: failed.length,
|
|
187
|
+
runs: reports.length,
|
|
188
|
+
branch,
|
|
189
|
+
findings: findings.length,
|
|
190
|
+
})
|
|
191
|
+
: running.length > 0
|
|
192
|
+
? msg('cli.ci.running', { running: running.length, runs: reports.length, branch })
|
|
193
|
+
: msg('cli.ci.green', { runs: reports.length, branch }),
|
|
194
|
+
findings,
|
|
195
|
+
lines: reports.flatMap(runLines),
|
|
196
|
+
data: {
|
|
197
|
+
repo: repo.slug,
|
|
198
|
+
branch,
|
|
199
|
+
counts: {
|
|
200
|
+
runs: reports.length,
|
|
201
|
+
failed: failed.length,
|
|
202
|
+
running: running.length,
|
|
203
|
+
findings: findings.length,
|
|
204
|
+
},
|
|
205
|
+
runs: reports.map(runJson),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runLines(entry: RunReport): readonly string[] {
|
|
211
|
+
const out = [
|
|
212
|
+
msg('cli.ci.run', {
|
|
213
|
+
conclusion: conclusionOf(entry.run),
|
|
214
|
+
workflow: entry.run.workflow,
|
|
215
|
+
url: entry.run.url,
|
|
216
|
+
}),
|
|
217
|
+
];
|
|
218
|
+
const failures = entry.failures ?? [];
|
|
219
|
+
for (const failure of failures) {
|
|
220
|
+
out.push(
|
|
221
|
+
msg('cli.ci.job', {
|
|
222
|
+
conclusion: failure.conclusion,
|
|
223
|
+
job: failure.job,
|
|
224
|
+
steps: failure.failedSteps.join(', '),
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
if (failure.tail.length === 0) {
|
|
228
|
+
out.push(msg('cli.ci.logs.empty', { url: failure.url }));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out.push(msg('cli.ci.tail', { job: failure.job }));
|
|
232
|
+
for (const line of failure.tail) out.push(` | ${line}`);
|
|
233
|
+
}
|
|
234
|
+
const jobs = entry.jobs;
|
|
235
|
+
if (jobs !== undefined && jobs.length > failures.length) {
|
|
236
|
+
out.push(msg('cli.ci.jobs.other', { count: jobs.length - failures.length }));
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runJson(entry: RunReport): JsonValue {
|
|
242
|
+
return {
|
|
243
|
+
id: entry.run.id,
|
|
244
|
+
workflow: entry.run.workflow,
|
|
245
|
+
status: entry.run.status,
|
|
246
|
+
conclusion: entry.run.conclusion,
|
|
247
|
+
title: entry.run.title,
|
|
248
|
+
url: entry.run.url,
|
|
249
|
+
createdAt: entry.run.createdAt,
|
|
250
|
+
// Absent rather than empty on a run that was never opened: `jobs: []` on a green run would
|
|
251
|
+
// claim the run has no jobs, which is a different statement from "nobody asked".
|
|
252
|
+
...(entry.jobs === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: {
|
|
255
|
+
jobs: entry.jobs.map((job) => ({
|
|
256
|
+
name: job.name,
|
|
257
|
+
status: job.status,
|
|
258
|
+
conclusion: job.conclusion,
|
|
259
|
+
url: job.url,
|
|
260
|
+
})),
|
|
261
|
+
}),
|
|
262
|
+
...(entry.failures === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: {
|
|
265
|
+
failures: entry.failures.map((failure) => ({
|
|
266
|
+
job: failure.job,
|
|
267
|
+
url: failure.url,
|
|
268
|
+
failedSteps: [...failure.failedSteps],
|
|
269
|
+
tail: [...failure.tail],
|
|
270
|
+
})),
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}
|