@datadisco/qa 0.1.1 → 0.2.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/README.md +52 -7
- package/dist/cli.js +122 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
CI companion for [DataDisco QA](https://datadisco.com) — PR-triggered persona
|
|
4
4
|
simulation ("confirmation testing for agentic teams").
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i -g @datadisco/qa # or run ad hoc: npx @datadisco/qa <command>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Most teams consume it through the
|
|
13
|
+
[`Data-Disco-Inc/qa-action`](https://github.com/Data-Disco-Inc/qa-action)
|
|
14
|
+
GitHub Action, which wraps this CLI — see its README for the workflow snippet.
|
|
9
15
|
|
|
10
16
|
## Why this exists
|
|
11
17
|
|
|
@@ -48,6 +54,22 @@ datadisco-qa report-preview \
|
|
|
48
54
|
--repo acme/site --pr 482 --sha "$GIT_SHA"
|
|
49
55
|
```
|
|
50
56
|
|
|
57
|
+
The server debounces rapid pushes for up to 10 minutes before creating the QA
|
|
58
|
+
run row, and CI often reports the preview URL before that row exists. By
|
|
59
|
+
default `report-preview` handles this for you: on the server's "no QA run is
|
|
60
|
+
waiting on a preview URL yet" response it retries every 30s for up to
|
|
61
|
+
**12 minutes**, then fails with a hint if the run never showed up. Any other
|
|
62
|
+
failure (bad token, invalid URL, repo not connected) still fails immediately —
|
|
63
|
+
only the "not created yet" case is retried. The server can also mark an error
|
|
64
|
+
as permanently non-retryable (e.g. the PR is closed, a draft, or the head SHA
|
|
65
|
+
is stale) — those fail immediately too, with the server's error code in the
|
|
66
|
+
message, instead of burning the retry window.
|
|
67
|
+
|
|
68
|
+
| Flag | Effect |
|
|
69
|
+
| ------------------------------ | -------------------------------------------------------- |
|
|
70
|
+
| `--no-wait-for-run` | Fail immediately instead of retrying (previous behavior) |
|
|
71
|
+
| `--wait-run-timeout <minutes>` | Cap the retry window (default 12) |
|
|
72
|
+
|
|
51
73
|
### `wait`
|
|
52
74
|
|
|
53
75
|
Block until the PR's QA run reaches a verdict, printing each phase transition.
|
|
@@ -92,8 +114,31 @@ node dist/cli.js --help
|
|
|
92
114
|
Same toolchain as [datadisco-mcp](https://github.com/Data-Disco-Inc/datadisco-mcp):
|
|
93
115
|
tsup, vitest, oxlint/oxfmt, cac.
|
|
94
116
|
|
|
95
|
-
##
|
|
117
|
+
## Releasing
|
|
118
|
+
|
|
119
|
+
Publishing is automated via npm [trusted publishing](https://docs.npmjs.com/trusted-publishers)
|
|
120
|
+
(OIDC — no token). Cut a release with:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
npm version <patch|minor|major> # bumps package.json + lockfile, commits, tags vX.Y.Z
|
|
124
|
+
git push origin main --follow-tags
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Pushing the `v*` tag runs [`release.yml`](.github/workflows/release.yml), which
|
|
128
|
+
verifies the tag matches `package.json`, runs the tests, and `npm publish`es
|
|
129
|
+
with provenance. Prerelease tags (`vX.Y.Z-beta.1`) publish under the `next`
|
|
130
|
+
dist-tag so `latest` keeps pointing at the newest stable release.
|
|
131
|
+
|
|
132
|
+
### Lockfile note
|
|
96
133
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
The toolchain (rolldown/rollup/esbuild) ships platform-native binaries plus a
|
|
135
|
+
wasm fallback whose `@emnapi/*` deps [npm drops from a macOS-generated
|
|
136
|
+
lockfile](https://github.com/npm/cli/issues/4828) — which then fails `npm ci` on
|
|
137
|
+
the Linux CI runner. A plain `npm install` locally is fine for day-to-day work
|
|
138
|
+
(just don't commit the resulting lockfile). If you actually change dependencies,
|
|
139
|
+
regenerate the lockfile on Linux so it stays complete:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
rm -f package-lock.json
|
|
143
|
+
docker run --rm --platform linux/amd64 -v "$PWD":/app -w /app node:20 npm install
|
|
144
|
+
```
|
package/dist/cli.js
CHANGED
|
@@ -148,7 +148,13 @@ async function reportPreview(config, params, fetchImpl) {
|
|
|
148
148
|
if (response.ok && isString(payload.runId)) {
|
|
149
149
|
return { ok: true, runId: payload.runId };
|
|
150
150
|
}
|
|
151
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
status: response.status,
|
|
154
|
+
error: errorMessage(payload),
|
|
155
|
+
retryable: booleanOrUndefined(payload.retryable),
|
|
156
|
+
code: stringOrUndefined2(payload.code)
|
|
157
|
+
};
|
|
152
158
|
}
|
|
153
159
|
async function getRunStatus(config, params, fetchImpl) {
|
|
154
160
|
const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
|
|
@@ -183,6 +189,12 @@ function isRecord2(value) {
|
|
|
183
189
|
function isString(value) {
|
|
184
190
|
return typeof value === "string";
|
|
185
191
|
}
|
|
192
|
+
function stringOrUndefined2(value) {
|
|
193
|
+
return isString(value) ? value : void 0;
|
|
194
|
+
}
|
|
195
|
+
function booleanOrUndefined(value) {
|
|
196
|
+
return typeof value === "boolean" ? value : void 0;
|
|
197
|
+
}
|
|
186
198
|
function isRunStatus(value) {
|
|
187
199
|
return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
|
|
188
200
|
}
|
|
@@ -212,56 +224,6 @@ var defaultDeps = {
|
|
|
212
224
|
now: () => Date.now()
|
|
213
225
|
};
|
|
214
226
|
|
|
215
|
-
// src/commands/report-preview.ts
|
|
216
|
-
async function runReportPreview(options, deps = defaultDeps) {
|
|
217
|
-
if (!options.url) {
|
|
218
|
-
deps.logger.error("--url is required (the deployed preview URL).");
|
|
219
|
-
return 1;
|
|
220
|
-
}
|
|
221
|
-
assertValidPreviewUrl(options.url);
|
|
222
|
-
const config = resolveConfig(options);
|
|
223
|
-
const context = resolveRunContext(options);
|
|
224
|
-
const api = deps.createApi(config);
|
|
225
|
-
const outcome = await api.reportPreview({ ...context, url: options.url });
|
|
226
|
-
if (!outcome.ok) {
|
|
227
|
-
deps.logger.error(`Could not report preview URL: ${outcome.error}`);
|
|
228
|
-
return 1;
|
|
229
|
-
}
|
|
230
|
-
deps.logger.success(
|
|
231
|
-
`Preview reported for ${context.repo}#${context.prNumber} \u2014 QA run ${outcome.runId} queued.`
|
|
232
|
-
);
|
|
233
|
-
return 0;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// src/git.ts
|
|
237
|
-
import { execFileSync } from "child_process";
|
|
238
|
-
var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
|
239
|
-
function detectRepoFullName(run2 = defaultRunner) {
|
|
240
|
-
const remote = tryGit(run2, ["remote", "get-url", "origin"]);
|
|
241
|
-
return remote ? parseRepoFromRemote(remote) : void 0;
|
|
242
|
-
}
|
|
243
|
-
function detectHeadSha(run2 = defaultRunner) {
|
|
244
|
-
return tryGit(run2, ["rev-parse", "HEAD"]);
|
|
245
|
-
}
|
|
246
|
-
function parseRepoFromRemote(remote) {
|
|
247
|
-
const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
|
|
248
|
-
return match ? match[1] : void 0;
|
|
249
|
-
}
|
|
250
|
-
function tryGit(run2, args) {
|
|
251
|
-
try {
|
|
252
|
-
return run2(args) || void 0;
|
|
253
|
-
} catch {
|
|
254
|
-
return void 0;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// src/tunnel-provider.ts
|
|
259
|
-
import localtunnel from "localtunnel";
|
|
260
|
-
async function openTunnel(port) {
|
|
261
|
-
const tunnel = await localtunnel({ port });
|
|
262
|
-
return { url: tunnel.url, close: () => tunnel.close() };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
227
|
// src/verdict.ts
|
|
266
228
|
var EXIT_CODE = {
|
|
267
229
|
PASSED: 0,
|
|
@@ -366,11 +328,15 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
|
|
|
366
328
|
await deps.sleep(POLL_INTERVAL_MS);
|
|
367
329
|
}
|
|
368
330
|
}
|
|
369
|
-
|
|
370
|
-
|
|
331
|
+
var DEFAULT_TIMEOUT_FLAG = {
|
|
332
|
+
flag: "--timeout",
|
|
333
|
+
defaultMinutes: DEFAULT_TIMEOUT_MINUTES
|
|
334
|
+
};
|
|
335
|
+
function resolveTimeoutMs(timeout, spec = DEFAULT_TIMEOUT_FLAG) {
|
|
336
|
+
if (timeout === void 0) return spec.defaultMinutes * MINUTE_MS;
|
|
371
337
|
const minutes = Number(timeout);
|
|
372
338
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
373
|
-
throw new ConfigError(
|
|
339
|
+
throw new ConfigError(`${spec.flag} must be a positive number of minutes, got "${timeout}".`);
|
|
374
340
|
}
|
|
375
341
|
return minutes * MINUTE_MS;
|
|
376
342
|
}
|
|
@@ -378,6 +344,101 @@ function statusLine(run2) {
|
|
|
378
344
|
return run2.statusMessage ? `${run2.status} \u2014 ${run2.statusMessage}` : run2.status;
|
|
379
345
|
}
|
|
380
346
|
|
|
347
|
+
// src/commands/report-preview.ts
|
|
348
|
+
var RUN_NOT_CREATED_YET_STATUS = 409;
|
|
349
|
+
var RETRY_INTERVAL_MS = 3e4;
|
|
350
|
+
var DEFAULT_WAIT_RUN_TIMEOUT_MINUTES = 12;
|
|
351
|
+
var WAIT_RUN_TIMEOUT_SPEC = {
|
|
352
|
+
flag: "--wait-run-timeout",
|
|
353
|
+
defaultMinutes: DEFAULT_WAIT_RUN_TIMEOUT_MINUTES
|
|
354
|
+
};
|
|
355
|
+
async function runReportPreview(options, deps = defaultDeps) {
|
|
356
|
+
if (!options.url) {
|
|
357
|
+
deps.logger.error("--url is required (the deployed preview URL).");
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
assertValidPreviewUrl(options.url);
|
|
361
|
+
const config = resolveConfig(options);
|
|
362
|
+
const context = resolveRunContext(options);
|
|
363
|
+
const waitRunTimeoutMs = resolveTimeoutMs(options.waitRunTimeout, WAIT_RUN_TIMEOUT_SPEC);
|
|
364
|
+
const api = deps.createApi(config);
|
|
365
|
+
const outcome = await reportPreviewUntilRunExists(api, context, options.url, deps, {
|
|
366
|
+
waitForRun: options.waitForRun !== false,
|
|
367
|
+
waitRunTimeoutMs
|
|
368
|
+
});
|
|
369
|
+
if (!outcome.ok) {
|
|
370
|
+
deps.logger.error(`Could not report preview URL: ${describeFailure(outcome)}`);
|
|
371
|
+
return 1;
|
|
372
|
+
}
|
|
373
|
+
deps.logger.success(
|
|
374
|
+
`Preview reported for ${context.repo}#${context.prNumber} \u2014 QA run ${outcome.runId} queued.`
|
|
375
|
+
);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
async function reportPreviewUntilRunExists(api, context, url, deps, policy) {
|
|
379
|
+
const firstAttempt = await api.reportPreview({ ...context, url });
|
|
380
|
+
if (firstAttempt.ok || !isRunNotCreatedYet(firstAttempt) || !policy.waitForRun) {
|
|
381
|
+
return firstAttempt;
|
|
382
|
+
}
|
|
383
|
+
return retryReportPreviewUntilRunExists(api, context, url, deps, policy.waitRunTimeoutMs);
|
|
384
|
+
}
|
|
385
|
+
function isRunNotCreatedYet(outcome) {
|
|
386
|
+
return !outcome.ok && outcome.status === RUN_NOT_CREATED_YET_STATUS && outcome.retryable !== false;
|
|
387
|
+
}
|
|
388
|
+
function describeFailure(outcome) {
|
|
389
|
+
return outcome.code ? `${outcome.error} [${outcome.code}]` : outcome.error;
|
|
390
|
+
}
|
|
391
|
+
async function retryReportPreviewUntilRunExists(api, context, url, deps, waitRunTimeoutMs) {
|
|
392
|
+
const deadline = deps.now() + waitRunTimeoutMs;
|
|
393
|
+
deps.logger.info(
|
|
394
|
+
`QA run not created yet \u2014 the server may still be debouncing this push. Retrying for up to ${formatMinutes(waitRunTimeoutMs)} min\u2026`
|
|
395
|
+
);
|
|
396
|
+
while (true) {
|
|
397
|
+
await deps.sleep(Math.min(RETRY_INTERVAL_MS, deadline - deps.now()));
|
|
398
|
+
const outcome = await api.reportPreview({ ...context, url });
|
|
399
|
+
if (outcome.ok || !isRunNotCreatedYet(outcome)) return outcome;
|
|
400
|
+
if (deps.now() >= deadline) {
|
|
401
|
+
return { ...outcome, error: withRunNeverAppearedHint(outcome.error) };
|
|
402
|
+
}
|
|
403
|
+
deps.logger.info("Still waiting for the QA run to be created\u2026");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function formatMinutes(ms) {
|
|
407
|
+
return Math.round(ms / MINUTE_MS * 100) / 100;
|
|
408
|
+
}
|
|
409
|
+
function withRunNeverAppearedHint(error) {
|
|
410
|
+
return `${error} (gave up waiting \u2014 the QA run never appeared)`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/git.ts
|
|
414
|
+
import { execFileSync } from "child_process";
|
|
415
|
+
var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
|
416
|
+
function detectRepoFullName(run2 = defaultRunner) {
|
|
417
|
+
const remote = tryGit(run2, ["remote", "get-url", "origin"]);
|
|
418
|
+
return remote ? parseRepoFromRemote(remote) : void 0;
|
|
419
|
+
}
|
|
420
|
+
function detectHeadSha(run2 = defaultRunner) {
|
|
421
|
+
return tryGit(run2, ["rev-parse", "HEAD"]);
|
|
422
|
+
}
|
|
423
|
+
function parseRepoFromRemote(remote) {
|
|
424
|
+
const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
|
|
425
|
+
return match ? match[1] : void 0;
|
|
426
|
+
}
|
|
427
|
+
function tryGit(run2, args) {
|
|
428
|
+
try {
|
|
429
|
+
return run2(args) || void 0;
|
|
430
|
+
} catch {
|
|
431
|
+
return void 0;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/tunnel-provider.ts
|
|
436
|
+
import localtunnel from "localtunnel";
|
|
437
|
+
async function openTunnel(port) {
|
|
438
|
+
const tunnel = await localtunnel({ port });
|
|
439
|
+
return { url: tunnel.url, close: () => tunnel.close() };
|
|
440
|
+
}
|
|
441
|
+
|
|
381
442
|
// src/commands/tunnel.ts
|
|
382
443
|
var defaultTunnelDeps = {
|
|
383
444
|
...defaultDeps,
|
|
@@ -437,7 +498,13 @@ async function runWait(options, deps = defaultDeps) {
|
|
|
437
498
|
|
|
438
499
|
// src/cli.ts
|
|
439
500
|
var cli = cac("datadisco-qa");
|
|
440
|
-
cli.command("report-preview", "Report a PR's preview-deploy URL so its pending QA run can start").option("--url <url>", "The deployed preview URL for the PR head SHA").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA the preview was built from").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").
|
|
501
|
+
cli.command("report-preview", "Report a PR's preview-deploy URL so its pending QA run can start").option("--url <url>", "The deployed preview URL for the PR head SHA").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA the preview was built from").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").option(
|
|
502
|
+
"--no-wait-for-run",
|
|
503
|
+
"Fail immediately if the QA run hasn't been created yet, instead of retrying"
|
|
504
|
+
).option(
|
|
505
|
+
"--wait-run-timeout <minutes>",
|
|
506
|
+
"How long to retry while the QA run hasn't been created yet (default 12)"
|
|
507
|
+
).action((options) => run(() => runReportPreview(options)));
|
|
441
508
|
cli.command("wait", "Block until the PR's QA run reaches a verdict").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--timeout <minutes>", "Give up after this many minutes (default 30)").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").action((options) => run(() => runWait(options)));
|
|
442
509
|
cli.command("tunnel", "Expose a local build and start a QA run against it").option("--port <port>", "Local port serving the build").option("--url <url>", "Use an externally managed tunnel URL instead of --port").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA (defaults to the local git HEAD)").option("--no-wait", "Queue the run and exit without waiting for a verdict").option("--timeout <minutes>", "Give up waiting after this many minutes").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").action((options) => run(() => runTunnel(options)));
|
|
443
510
|
async function run(command) {
|
package/package.json
CHANGED