@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Every `solid-js` import in an island chunk resolves to Solid's PRODUCTION browser build.
|
|
2
|
+
// `Bun.build({ target: 'browser' })` always adds the `development` export condition and offers no
|
|
3
|
+
// option that removes it — `conditions`, `production`, `env` and `define` were each measured under
|
|
4
|
+
// Bun 1.4 and none of them does — so without this seam an island ships the dev bundle silently.
|
|
5
|
+
|
|
6
|
+
// `node:path` by necessity: Bun ships no path API, and this file resolves a package entry back
|
|
7
|
+
// to the directory its `exports` map is relative to.
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import type { BunPlugin } from 'bun';
|
|
10
|
+
import { IslandBuildFailedError } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The conditions an island's `solid-js` subpath is resolved under. `development` is the one NOT in
|
|
14
|
+
* the set, which is the whole point of the file; `production` is in it because an island chunk is
|
|
15
|
+
* only ever built to be shipped — `x dev` serves the same chunk the container does, so a second
|
|
16
|
+
* answer here would be a bundle the byte budget never measured.
|
|
17
|
+
*/
|
|
18
|
+
const ISLAND_CONDITIONS: ReadonlySet<string> = new Set([
|
|
19
|
+
'production',
|
|
20
|
+
'browser',
|
|
21
|
+
'module',
|
|
22
|
+
'import',
|
|
23
|
+
'default',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** `solid-js` and its subpaths, and nothing else: Solid is the runtime an island is compiled for. */
|
|
27
|
+
const SOLID_SPECIFIER = /^solid-js(?:\/|$)/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Node's conditional-exports walk, restricted to what this file needs: the first key of the object
|
|
31
|
+
* that the build's condition set contains, depth-first, with an array as an ordered fallback list.
|
|
32
|
+
* Written out rather than delegated to `Bun.resolveSync` because the ONE thing it has to do
|
|
33
|
+
* differently from Bun's resolver is refuse `development` — and `types` with it, which would
|
|
34
|
+
* otherwise win on Solid's map and hand the bundler a `.d.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export function selectCondition(node: unknown, conditions: ReadonlySet<string>): string | null {
|
|
37
|
+
if (typeof node === 'string') return node;
|
|
38
|
+
if (Array.isArray(node)) {
|
|
39
|
+
for (const alternative of node as readonly unknown[]) {
|
|
40
|
+
const picked = selectCondition(alternative, conditions);
|
|
41
|
+
if (picked !== null) return picked;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof node !== 'object' || node === null) return null;
|
|
46
|
+
for (const [condition, value] of Object.entries(node)) {
|
|
47
|
+
if (!conditions.has(condition)) continue;
|
|
48
|
+
const picked = selectCondition(value, conditions);
|
|
49
|
+
if (picked !== null) return picked;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One parse per manifest: an island imports Solid from several files, and every file asks again. */
|
|
55
|
+
const exportsCache = new Map<string, unknown>();
|
|
56
|
+
|
|
57
|
+
async function exportsOf(manifest: string): Promise<unknown> {
|
|
58
|
+
const hit = exportsCache.get(manifest);
|
|
59
|
+
if (hit !== undefined || exportsCache.has(manifest)) return hit;
|
|
60
|
+
const parsed: unknown = JSON.parse(await Bun.file(manifest).text());
|
|
61
|
+
const field =
|
|
62
|
+
typeof parsed === 'object' && parsed !== null && 'exports' in parsed
|
|
63
|
+
? (parsed as { readonly exports?: unknown }).exports
|
|
64
|
+
: undefined;
|
|
65
|
+
exportsCache.set(manifest, field);
|
|
66
|
+
return field;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test seam: the cache is process-global because `x dev` rebuilds in one process. */
|
|
70
|
+
export function clearSolidExportsCache(): void {
|
|
71
|
+
exportsCache.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The absolute file `specifier` must resolve to, or `null` for "Bun's own answer is already the
|
|
76
|
+
* right one" — which is every subpath Solid declares as a plain string or a pattern, since a
|
|
77
|
+
* declaration with no conditions on it cannot select the development build.
|
|
78
|
+
*/
|
|
79
|
+
export async function solidProductionEntry(
|
|
80
|
+
specifier: string,
|
|
81
|
+
resolveDir: string,
|
|
82
|
+
importer: string,
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
let manifest: string;
|
|
85
|
+
try {
|
|
86
|
+
// `solid-js/package.json` is an `exports` entry of Solid's own map, so this reaches the exact
|
|
87
|
+
// copy the island would have imported — not a hoisted sibling at a different version.
|
|
88
|
+
manifest = Bun.resolveSync('solid-js/package.json', resolveDir);
|
|
89
|
+
} catch {
|
|
90
|
+
// Solid is not installed here. Bun's resolver says so, in its own words, naming the importer.
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const field = await exportsOf(manifest);
|
|
94
|
+
const subpath = specifier === 'solid-js' ? '.' : `.${specifier.slice('solid-js'.length)}`;
|
|
95
|
+
if (typeof field !== 'object' || field === null || !(subpath in field)) return null;
|
|
96
|
+
|
|
97
|
+
const entry = selectCondition((field as Record<string, unknown>)[subpath], ISLAND_CONDITIONS);
|
|
98
|
+
const file = entry === null ? null : join(dirname(manifest), entry);
|
|
99
|
+
if (file === null || !(await Bun.file(file).exists())) {
|
|
100
|
+
throw new IslandBuildFailedError({
|
|
101
|
+
file: importer.length > 0 ? importer : specifier,
|
|
102
|
+
logs:
|
|
103
|
+
`${specifier} has no production browser entry: ${manifest} answers ` +
|
|
104
|
+
`${entry === null ? 'nothing' : JSON.stringify(entry)} under ` +
|
|
105
|
+
`[${[...ISLAND_CONDITIONS].join(', ')}], and an island may not ship Solid's ` +
|
|
106
|
+
'development build',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return file;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The plugin `island-bundle.ts` hands `Bun.build`, beside `solidJsxPlugin`. Stateless apart from
|
|
114
|
+
* the manifest cache, so one frozen descriptor serves every concurrent island build.
|
|
115
|
+
*
|
|
116
|
+
* The absolute path it answers with no longer matches `SOLID_SPECIFIER`, so Solid's own internal
|
|
117
|
+
* `import … from 'solid-js'` is the only re-entry — and that one is wanted: it is how `web.js`
|
|
118
|
+
* reaches `solid.js` rather than `dev.js`.
|
|
119
|
+
*/
|
|
120
|
+
export const solidProductionPlugin: BunPlugin = {
|
|
121
|
+
name: 'ultimate-island-solid-production',
|
|
122
|
+
setup(build): void {
|
|
123
|
+
build.onResolve({ filter: SOLID_SPECIFIER }, async ({ path, importer, resolveDir }) => {
|
|
124
|
+
const from = resolveDir.length > 0 ? resolveDir : dirname(importer);
|
|
125
|
+
const entry = await solidProductionEntry(path, from, importer);
|
|
126
|
+
return entry === null ? undefined : { path: entry };
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The stylesheet half of an island build: `import styles from './x.module.scss'` answers the class
|
|
2
|
+
// map the SERVER hashed, and the CSS lands in the same registry a document renders from. Bun's
|
|
3
|
+
// default loader answers the asset PATH — a string — so `styles['track']` is `undefined`, every
|
|
4
|
+
// element renders unclassed, and `Bun.build` reports `success: true` with no log.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { loadStylesheet } from '@ultimat3/render';
|
|
8
|
+
import type { BunPlugin } from 'bun';
|
|
9
|
+
import { IslandBuildFailedError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same spelling `installRenderLoader` filters on, so there is ONE answer to "what is a
|
|
13
|
+
* stylesheet import" across the server loader and the island bundler. A plain `.css`/`.scss` is in
|
|
14
|
+
* deliberately: it compiles to an empty class map and registers its rules, which is what a global
|
|
15
|
+
* stylesheet an island imports has to do.
|
|
16
|
+
*/
|
|
17
|
+
const STYLESHEET = /\.s?css$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `loadStylesheet`, not `compileStylesheet`: compiling alone answers the class names and drops the
|
|
21
|
+
* RULES on the floor, and an island is the one importer a document's own module graph never sees.
|
|
22
|
+
* Registering here is what puts them in `stylesFor(surface)` — `buildIslands` runs before the first
|
|
23
|
+
* document is rendered, in `x dev` and in `prerenderSite` alike.
|
|
24
|
+
*/
|
|
25
|
+
export const islandStylesPlugin: BunPlugin = {
|
|
26
|
+
name: 'ultimate-island-styles',
|
|
27
|
+
setup(build): void {
|
|
28
|
+
build.onLoad({ filter: STYLESHEET }, async ({ path }) => {
|
|
29
|
+
const source = await Bun.file(path).text();
|
|
30
|
+
try {
|
|
31
|
+
return { contents: loadStylesheet(path, source), loader: 'js' };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// A coded failure already names the file and carries a fix — re-wrapping it would bury
|
|
34
|
+
// both. Anything else is rendered through `renderThrowable`, because this is the plugin's
|
|
35
|
+
// last frame and a value that fights being read would escape a Bun plugin as a bare throw.
|
|
36
|
+
if (error instanceof UltimateError) throw error;
|
|
37
|
+
throw new IslandBuildFailedError({ file: path, logs: renderThrowable(error) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
package/src/mcp-errors.ts
CHANGED
|
@@ -48,6 +48,15 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
48
48
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
49
49
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
50
50
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
51
|
+
X_WORKSPACE_DEP_UNDECLARED:
|
|
52
|
+
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
53
|
+
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
54
|
+
X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
|
|
55
|
+
X_GH_NOT_AUTHENTICATED: 'gh auth login',
|
|
56
|
+
X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
|
|
57
|
+
X_GH_RESPONSE_INVALID: 'x pr review --json # the finding names the field that did not parse',
|
|
58
|
+
X_PR_NOT_FOUND: 'x pr review --pr 1 --json # or open one first with: gh pr create',
|
|
59
|
+
X_CI_RUN_NOT_FOUND: 'x ci --branch main --json',
|
|
51
60
|
X_ERROR_CODE_UNDOCUMENTED: 'x verify --json # the finding names the code and the missing page',
|
|
52
61
|
X_ERROR_CODE_UNREGISTERED:
|
|
53
62
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
package/src/messages.ts
CHANGED
|
@@ -159,7 +159,71 @@ const CATALOG = {
|
|
|
159
159
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
160
160
|
'cli.tasks.count': '{count} task(s)',
|
|
161
161
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
162
|
+
'cli.affected.count':
|
|
163
|
+
'{count} workspace(s) affected by {base}...HEAD, from {changed} changed file(s)',
|
|
164
|
+
'cli.affected.dirty':
|
|
165
|
+
' including the working tree (--dirty): every uncommitted change in this checkout, whoever made it',
|
|
166
|
+
'cli.affected.none': 'no workspace is affected by {base}...HEAD, from {changed} changed file(s)',
|
|
167
|
+
'cli.affected.rootWide':
|
|
168
|
+
' every workspace: {files} belongs to none of them and changes what all of them compile',
|
|
169
|
+
// `x shot` — the picture is the `lines`, the verdict is the artifact. The summary names the
|
|
170
|
+
// GATING fact, and a redirect comes first: a photograph of the sign-in page with every island
|
|
171
|
+
// missing reads as a bug in the app, and it is a bug in the capture.
|
|
172
|
+
'cli.shot.ok': '{route} clean — {islands} island(s) mounted, nothing logged and nothing threw',
|
|
173
|
+
'cli.shot.errors': '{route}: {errors} console error(s) — verdict.json names each one',
|
|
174
|
+
'cli.shot.redirected': '{route} redirected to {url} — the picture is not the route asked for',
|
|
175
|
+
'cli.shot.server.booted': ' server booted for this shot on {url}',
|
|
176
|
+
'cli.shot.server.reused': ' server the x dev already running on {url}',
|
|
177
|
+
'cli.shot.canvas': ' canvas {width}x{height}',
|
|
178
|
+
'cli.shot.canvasUnreadable': ' canvas unreadable — {bytes} byte(s), not a decodable image',
|
|
179
|
+
'cli.shot.islands': ' islands {booted} of {declared} mounted ({strategies})',
|
|
180
|
+
'cli.shot.islandsUnknown': ' islands not counted — the page answered no probe',
|
|
181
|
+
'cli.shot.network': ' network {requests} request(s), {refused} refused, {dropped} dropped',
|
|
182
|
+
'cli.shot.console': ' console {level}: {text}',
|
|
183
|
+
'cli.shot.threw': '{route}: {thrown} uncaught exception(s) — {first}',
|
|
184
|
+
'cli.shot.pageError': ' threw {message} {at}',
|
|
185
|
+
'cli.shot.picture': ' picture {path}',
|
|
186
|
+
'cli.shot.verdict': ' verdict {path}',
|
|
187
|
+
'cli.shot.blind.status':
|
|
188
|
+
'HTTP response status is not observed — the port records requests, never responses',
|
|
189
|
+
'cli.ci.failed':
|
|
190
|
+
'{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
|
|
191
|
+
'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
|
|
192
|
+
'cli.ci.job': ' {conclusion} {job} ({steps})',
|
|
193
|
+
'cli.ci.jobs.other': ' {count} other job(s) in this run',
|
|
194
|
+
'cli.ci.logs.empty': ' the failed step wrote no log — {url}',
|
|
195
|
+
/** The conclusion of a run GitHub has not finished — a value, not a column key. */
|
|
196
|
+
'cli.ci.pending': 'pending',
|
|
197
|
+
'cli.ci.run': '{conclusion} {workflow} {url}',
|
|
198
|
+
'cli.ci.running':
|
|
199
|
+
'{running} of {runs} workflow run(s) on {branch} has not finished — nothing has failed yet',
|
|
200
|
+
'cli.ci.tail': ' log tail, {job}:',
|
|
201
|
+
'cli.pr.body.truncated': ' … {hidden} more line(s) — re-run with --full',
|
|
202
|
+
/** The line of a thread whose anchor GitHub answers null for — a value, not a column key. */
|
|
203
|
+
'cli.pr.line.unknown': '-',
|
|
204
|
+
'cli.pr.replied': 'replied on thread {id}: {url}',
|
|
205
|
+
// Resolving closes a CONVERSATION. Whether the finding is fixed is a fact about the code that
|
|
206
|
+
// no GitHub mutation observes, and a summary saying "addressed" would assert one from the other.
|
|
207
|
+
'cli.pr.resolved':
|
|
208
|
+
'thread {id} is marked resolved on GitHub — that records the conversation, not that the finding is fixed',
|
|
209
|
+
'cli.pr.review.count':
|
|
210
|
+
'{unresolved} unresolved and {resolved} resolved review thread(s) on {repo}#{pr}',
|
|
211
|
+
'cli.pr.review.current': ' submitted against the current head {head}',
|
|
212
|
+
'cli.pr.review.decision': ' review: {decision} by {author} at {submitted}',
|
|
213
|
+
'cli.pr.review.none': 'no review thread is anchored to a line on {repo}#{pr}',
|
|
214
|
+
'cli.pr.review.stale':
|
|
215
|
+
' submitted against {commit}; the head is now {head} ({committed}) — this decision predates the current code',
|
|
216
|
+
'cli.pr.review.truncated':
|
|
217
|
+
' more than {count} threads — this is the first page, not the whole review',
|
|
218
|
+
'cli.pr.review.undecided': ' GitHub reports no review decision yet',
|
|
219
|
+
'cli.pr.thread.closed': ' resolved {path}:{line} {id}',
|
|
220
|
+
'cli.pr.thread.comment': ' {author} at {createdAt}',
|
|
221
|
+
'cli.pr.thread.more': ' {hidden} more comment(s) on this thread',
|
|
222
|
+
'cli.pr.thread.open': ' unresolved {path}:{line} {id}',
|
|
223
|
+
'cli.pr.thread.outdated':
|
|
224
|
+
' the diff has moved under this thread — {line} is where the comment was written',
|
|
162
225
|
'cli.test.fail': '{failed} of {workers} shard(s) failed',
|
|
226
|
+
'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
|
|
163
227
|
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
164
228
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
165
229
|
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The GraphQL a review lives behind, and the rows it becomes. `gh pr view --comments` shows ISSUE
|
|
2
|
+
// comments; the findings a reviewer anchored to a line are `reviewThreads`, and the thread id that
|
|
3
|
+
// resolves one exists in no REST response and in no web URL — this file is where that query stops
|
|
4
|
+
// being folklore.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/schema';
|
|
7
|
+
import type { GhHost } from './gh';
|
|
8
|
+
import { GhResponseInvalidError, ghGraphql } from './gh';
|
|
9
|
+
import type { GhRepo } from './gh-target';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many threads one page holds. The query is built from it, so the report and the request can
|
|
13
|
+
* never disagree about what "the first page" was.
|
|
14
|
+
*/
|
|
15
|
+
export const THREAD_PAGE = 100;
|
|
16
|
+
|
|
17
|
+
/** And how many comments of each. A long argument still shows the finding that opened it. */
|
|
18
|
+
export const COMMENT_PAGE = 10;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One document, because the two hazards are only visible together: a `reviewDecision` survives
|
|
22
|
+
* later pushes, so `CHANGES_REQUESTED` may predate the commits that addressed it, and the only
|
|
23
|
+
* thing that settles it is the head oid — asking for the threads and then asking for the head is
|
|
24
|
+
* two answers about two moments.
|
|
25
|
+
*/
|
|
26
|
+
export const REVIEW_QUERY =
|
|
27
|
+
'query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){' +
|
|
28
|
+
'pullRequest(number:$n){number url headRefOid reviewDecision ' +
|
|
29
|
+
'commits(last:1){nodes{commit{oid committedDate}}} ' +
|
|
30
|
+
'latestOpinionatedReviews(first:20){nodes{state submittedAt author{login} commit{oid}}} ' +
|
|
31
|
+
`reviewThreads(first:${THREAD_PAGE}){pageInfo{hasNextPage} nodes{id isResolved isOutdated ` +
|
|
32
|
+
`path line originalLine comments(first:${COMMENT_PAGE}){totalCount ` +
|
|
33
|
+
'nodes{author{login} createdAt body}}}}}}}';
|
|
34
|
+
|
|
35
|
+
const AUTHOR = t.nullable(t.object({ login: t.string }));
|
|
36
|
+
|
|
37
|
+
const REVIEW_RESPONSE = t.object({
|
|
38
|
+
data: t.object({
|
|
39
|
+
repository: t.nullable(
|
|
40
|
+
t.object({
|
|
41
|
+
pullRequest: t.nullable(
|
|
42
|
+
t.object({
|
|
43
|
+
number: t.number,
|
|
44
|
+
url: t.string,
|
|
45
|
+
headRefOid: t.string,
|
|
46
|
+
reviewDecision: t.nullable(t.string),
|
|
47
|
+
commits: t.object({
|
|
48
|
+
nodes: t.array(
|
|
49
|
+
t.object({ commit: t.object({ oid: t.string, committedDate: t.string }) }),
|
|
50
|
+
),
|
|
51
|
+
}),
|
|
52
|
+
latestOpinionatedReviews: t.object({
|
|
53
|
+
nodes: t.array(
|
|
54
|
+
t.object({
|
|
55
|
+
state: t.string,
|
|
56
|
+
submittedAt: t.nullable(t.string),
|
|
57
|
+
author: AUTHOR,
|
|
58
|
+
commit: t.nullable(t.object({ oid: t.string })),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
reviewThreads: t.object({
|
|
63
|
+
pageInfo: t.object({ hasNextPage: t.boolean }),
|
|
64
|
+
nodes: t.array(
|
|
65
|
+
t.object({
|
|
66
|
+
id: t.string,
|
|
67
|
+
isResolved: t.boolean,
|
|
68
|
+
isOutdated: t.boolean,
|
|
69
|
+
path: t.string,
|
|
70
|
+
line: t.nullable(t.number),
|
|
71
|
+
originalLine: t.nullable(t.number),
|
|
72
|
+
comments: t.object({
|
|
73
|
+
totalCount: t.number,
|
|
74
|
+
nodes: t.array(
|
|
75
|
+
t.object({ author: AUTHOR, createdAt: t.string, body: t.string }),
|
|
76
|
+
),
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export interface PrComment {
|
|
89
|
+
readonly author: string;
|
|
90
|
+
readonly createdAt: string;
|
|
91
|
+
readonly body: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PrThread {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly path: string;
|
|
97
|
+
/** The line the comment is anchored to NOW; `null` once the diff has moved under it. */
|
|
98
|
+
readonly line: number | null;
|
|
99
|
+
/** Where it was anchored when it was written — the only locator an outdated thread still has. */
|
|
100
|
+
readonly originalLine: number | null;
|
|
101
|
+
readonly isResolved: boolean;
|
|
102
|
+
readonly isOutdated: boolean;
|
|
103
|
+
readonly comments: readonly PrComment[];
|
|
104
|
+
/** Every comment on the thread, against the ten this page carries. */
|
|
105
|
+
readonly commentCount: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PrReview {
|
|
109
|
+
readonly state: string;
|
|
110
|
+
readonly author: string;
|
|
111
|
+
readonly submittedAt: string | null;
|
|
112
|
+
readonly commit: string | null;
|
|
113
|
+
/**
|
|
114
|
+
* The review was submitted against a commit that is no longer the head. A verdict about the
|
|
115
|
+
* COMMIT, not about the clock: two reviews seconds apart can straddle a push, and a timestamp
|
|
116
|
+
* comparison would call one of them current.
|
|
117
|
+
*/
|
|
118
|
+
readonly stale: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface PrReviewReport {
|
|
122
|
+
readonly repo: string;
|
|
123
|
+
readonly number: number;
|
|
124
|
+
readonly url: string;
|
|
125
|
+
readonly headSha: string;
|
|
126
|
+
readonly headCommittedAt: string | null;
|
|
127
|
+
readonly reviewDecision: string | null;
|
|
128
|
+
/** The review the decision came from, when one of the opinionated reviews states it. */
|
|
129
|
+
readonly decidingReview: PrReview | null;
|
|
130
|
+
readonly threads: readonly PrThread[];
|
|
131
|
+
/** More threads exist than one page holds, so `threads` is the first page and not the set. */
|
|
132
|
+
readonly truncated: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** GitHub answers `null` for a deleted account. A row with no author still carries its finding. */
|
|
136
|
+
const loginOf = (author: { readonly login: string } | null): string => author?.login ?? 'ghost';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The review a `reviewDecision` came from. GitHub derives the decision from every opinionated
|
|
140
|
+
* review at once, so the one that STATES it is the one to date — falling back to the newest, which
|
|
141
|
+
* is the only defensible guess when none of them spells the decision out.
|
|
142
|
+
*/
|
|
143
|
+
export function decidingReview(
|
|
144
|
+
reviews: readonly PrReview[],
|
|
145
|
+
decision: string | null,
|
|
146
|
+
): PrReview | null {
|
|
147
|
+
const stating = reviews.filter((review) => review.state === decision);
|
|
148
|
+
const pool = stating.length > 0 ? stating : reviews;
|
|
149
|
+
return pool.reduce<PrReview | null>(
|
|
150
|
+
(newest, review) =>
|
|
151
|
+
newest === null || (review.submittedAt ?? '') > (newest.submittedAt ?? '') ? review : newest,
|
|
152
|
+
null,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Unresolved first, then by file and line: the order an agent works the list in. */
|
|
157
|
+
export function orderThreads(threads: readonly PrThread[]): readonly PrThread[] {
|
|
158
|
+
return [...threads].sort((left, right) => {
|
|
159
|
+
if (left.isResolved !== right.isResolved) return left.isResolved ? 1 : -1;
|
|
160
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
161
|
+
return (left.line ?? left.originalLine ?? 0) - (right.line ?? right.originalLine ?? 0);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One round trip, one report. `repository` and `pullRequest` are both nullable in the schema
|
|
167
|
+
* because GraphQL answers `null` for either — but gh exits non-zero on the `errors` block that
|
|
168
|
+
* accompanies it, so reaching here with a `null` means GitHub answered a shape nobody has seen,
|
|
169
|
+
* and `X_GH_RESPONSE_INVALID` is a better sentence for that than a `TypeError` two frames later.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchReviewReport(
|
|
172
|
+
host: GhHost,
|
|
173
|
+
repo: GhRepo,
|
|
174
|
+
number: number,
|
|
175
|
+
): Promise<PrReviewReport | null> {
|
|
176
|
+
const response = await ghGraphql(
|
|
177
|
+
host,
|
|
178
|
+
REVIEW_QUERY,
|
|
179
|
+
{ owner: repo.owner, name: repo.name, n: number },
|
|
180
|
+
REVIEW_RESPONSE,
|
|
181
|
+
{
|
|
182
|
+
label: `gh api graphql (review threads on ${repo.slug}#${number})`,
|
|
183
|
+
fix: `gh pr view ${number} --repo ${repo.slug} --json number # confirm the pull request is visible to this token`,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const pull = response.data.repository?.pullRequest;
|
|
187
|
+
if (pull === undefined || pull === null) return null;
|
|
188
|
+
const head = pull.commits.nodes[0]?.commit ?? null;
|
|
189
|
+
const reviews: readonly PrReview[] = pull.latestOpinionatedReviews.nodes.map((review) => ({
|
|
190
|
+
state: review.state,
|
|
191
|
+
author: loginOf(review.author),
|
|
192
|
+
submittedAt: review.submittedAt,
|
|
193
|
+
commit: review.commit?.oid ?? null,
|
|
194
|
+
stale: review.commit?.oid !== pull.headRefOid,
|
|
195
|
+
}));
|
|
196
|
+
return {
|
|
197
|
+
repo: repo.slug,
|
|
198
|
+
number: pull.number,
|
|
199
|
+
url: pull.url,
|
|
200
|
+
headSha: pull.headRefOid,
|
|
201
|
+
headCommittedAt: head?.committedDate ?? null,
|
|
202
|
+
reviewDecision: pull.reviewDecision,
|
|
203
|
+
decidingReview: decidingReview(reviews, pull.reviewDecision),
|
|
204
|
+
truncated: pull.reviewThreads.pageInfo.hasNextPage,
|
|
205
|
+
threads: orderThreads(
|
|
206
|
+
pull.reviewThreads.nodes.map((thread) => ({
|
|
207
|
+
id: thread.id,
|
|
208
|
+
path: thread.path,
|
|
209
|
+
line: thread.line,
|
|
210
|
+
originalLine: thread.originalLine,
|
|
211
|
+
isResolved: thread.isResolved,
|
|
212
|
+
isOutdated: thread.isOutdated,
|
|
213
|
+
commentCount: thread.comments.totalCount,
|
|
214
|
+
comments: thread.comments.nodes.map((comment) => ({
|
|
215
|
+
author: loginOf(comment.author),
|
|
216
|
+
createdAt: comment.createdAt,
|
|
217
|
+
body: comment.body,
|
|
218
|
+
})),
|
|
219
|
+
})),
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const RESOLVE_MUTATION =
|
|
225
|
+
'mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{id isResolved}}}';
|
|
226
|
+
|
|
227
|
+
// Both levels nullable, because both are nullable in GitHub's schema (`ResolveReviewThreadPayload.
|
|
228
|
+
// thread` is an OBJECT, not a NON_NULL one) — and a payload that does not carry the thread it
|
|
229
|
+
// claims to have resolved is exactly the answer this command must not read as a success.
|
|
230
|
+
const RESOLVE_RESPONSE = t.object({
|
|
231
|
+
data: t.object({
|
|
232
|
+
resolveReviewThread: t.nullable(
|
|
233
|
+
t.object({ thread: t.nullable(t.object({ id: t.string, isResolved: t.boolean })) }),
|
|
234
|
+
),
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Marks the CONVERSATION resolved, and says only that. Whether the finding is addressed is a fact
|
|
240
|
+
* about the code, which no GitHub mutation can observe — the summary this returns feeds a line
|
|
241
|
+
* that refuses to conflate the two.
|
|
242
|
+
*/
|
|
243
|
+
export async function resolveThread(host: GhHost, threadId: string): Promise<string> {
|
|
244
|
+
const fix = 'x pr review --json # the id column is the thread id this mutation takes';
|
|
245
|
+
const label = `gh api graphql (resolve ${threadId})`;
|
|
246
|
+
const response = await ghGraphql(host, RESOLVE_MUTATION, { t: threadId }, RESOLVE_RESPONSE, {
|
|
247
|
+
label,
|
|
248
|
+
fix,
|
|
249
|
+
});
|
|
250
|
+
const thread = response.data.resolveReviewThread?.thread;
|
|
251
|
+
// A mutation that exited 0 and did not come back saying the thread is resolved has not told us
|
|
252
|
+
// it worked, and reporting `resolved` off the exit code alone is how a command claims an effect
|
|
253
|
+
// it never observed.
|
|
254
|
+
if (thread?.isResolved !== true) {
|
|
255
|
+
throw new GhResponseInvalidError({
|
|
256
|
+
label,
|
|
257
|
+
detail: 'the mutation returned no resolved thread',
|
|
258
|
+
fix,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// The id GitHub echoed, not the one that was sent: they are the same string on every success,
|
|
262
|
+
// and reporting the one we sent would make a summary that cannot tell a hit from a miss.
|
|
263
|
+
return thread.id;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const REPLY_MUTATION =
|
|
267
|
+
'mutation($t:ID!,$b:String!){addPullRequestReviewThreadReply(' +
|
|
268
|
+
'input:{pullRequestReviewThreadId:$t,body:$b}){comment{id url}}}';
|
|
269
|
+
|
|
270
|
+
const REPLY_RESPONSE = t.object({
|
|
271
|
+
data: t.object({
|
|
272
|
+
addPullRequestReviewThreadReply: t.nullable(
|
|
273
|
+
t.object({ comment: t.nullable(t.object({ id: t.string, url: t.string })) }),
|
|
274
|
+
),
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/** A reply lands IN the thread, which is the only place a reviewer reads it. Returns its URL. */
|
|
279
|
+
export async function replyToThread(host: GhHost, threadId: string, body: string): Promise<string> {
|
|
280
|
+
const fix = 'x pr review --json # the id column is the thread id this mutation takes';
|
|
281
|
+
const label = `gh api graphql (reply on ${threadId})`;
|
|
282
|
+
const response = await ghGraphql(host, REPLY_MUTATION, { t: threadId, b: body }, REPLY_RESPONSE, {
|
|
283
|
+
label,
|
|
284
|
+
fix,
|
|
285
|
+
});
|
|
286
|
+
const url = response.data.addPullRequestReviewThreadReply?.comment?.url;
|
|
287
|
+
if (url === undefined || url === null) {
|
|
288
|
+
throw new GhResponseInvalidError({ label, detail: 'the mutation posted no comment', fix });
|
|
289
|
+
}
|
|
290
|
+
return url;
|
|
291
|
+
}
|