@datadisco/qa 0.1.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +103 -11
  2. package/dist/cli.js +256 -88
  3. 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
- > **Status: pre-release.** The commands below are wired to the DataDisco QA
7
- > API but the package is not published to npm yet. Pin to the git repo, or
8
- > wait for the first tagged release.
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
 
@@ -32,29 +38,92 @@ to the hosted app and is overridable with `--api-url` / `DATADISCO_API_URL`.
32
38
  export DATADISCO_API_TOKEN=ddqa_xxx
33
39
  ```
34
40
 
41
+ ## Works in any CI
42
+
43
+ `report-preview` and `wait` auto-detect the repository, PR number, and head
44
+ SHA from whatever CI they're running in — GitHub Actions gets full support out
45
+ of the box, and every other CI (or a bare git checkout) still works with zero
46
+ flags in the common case:
47
+
48
+ ```sh
49
+ DATADISCO_API_TOKEN=ddqa_xxx npx @datadisco/qa report-preview --url https://pr-482.preview.acme.dev
50
+ ```
51
+
52
+ Each value is resolved through its own chain of fallbacks, in order, stopping
53
+ at the first one that resolves:
54
+
55
+ | Value | Resolution order |
56
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | Repository | `--repo` → `$GITHUB_REPOSITORY` → the git checkout's github.com `origin` remote |
58
+ | Head SHA | `--sha` → the GitHub Actions `pull_request` event payload → `$GITHUB_SHA` → the git checkout's `HEAD` |
59
+ | PR number | `--pr` → GitHub Actions (`GITHUB_REF` / event payload) → CircleCI, Jenkins, Buildkite, Travis CI, or Drone's PR env var → _(unresolved)_ |
60
+
61
+ `--repo`, `--pr`, and `--sha` always override auto-detection when passed.
62
+
63
+ Unlike the repository and head SHA, the PR number is allowed to stay
64
+ unresolved: `report-preview` will still report the preview URL with just the
65
+ repo + head SHA, and the DataDisco server resolves the PR from the commit
66
+ itself. `wait` needs _either_ a PR number or a head SHA to poll by — pass
67
+ `--sha` explicitly if neither auto-detects (e.g. a CI DataDisco doesn't
68
+ recognize yet).
69
+
70
+ | CI | Repo | Head SHA | PR number |
71
+ | ----------------- | ------------------- | ------------------------------------- | ----------------------------------------------------------------- |
72
+ | GitHub Actions | `GITHUB_REPOSITORY` | `pull_request` payload / `GITHUB_SHA` | `GITHUB_REF` / `pull_request` payload |
73
+ | CircleCI | git origin remote | git `HEAD` | `CIRCLE_PULL_REQUEST` / `CIRCLE_PR_NUMBER` |
74
+ | Jenkins | git origin remote | git `HEAD` | `CHANGE_ID` |
75
+ | Buildkite | git origin remote | git `HEAD` | `BUILDKITE_PULL_REQUEST` |
76
+ | Travis CI | git origin remote | git `HEAD` | `TRAVIS_PULL_REQUEST` |
77
+ | Drone | git origin remote | git `HEAD` | `DRONE_PULL_REQUEST` |
78
+ | Bare git checkout | git origin remote | git `HEAD` | not auto-detected — pass `--pr` or rely on server-side resolution |
79
+
80
+ The git `origin` remote fallback only recognizes github.com remotes (SSH or
81
+ HTTPS); for anything else, pass `--repo` explicitly.
82
+
35
83
  ## Commands
36
84
 
37
85
  ### `report-preview`
38
86
 
39
87
  Report the deployed preview URL for a PR head SHA so its pending QA run leaves
40
- `PENDING_PREVIEW` and starts. In GitHub Actions, `--repo`, `--pr`, and `--sha`
41
- are auto-detected from the `pull_request` event.
88
+ `PENDING_PREVIEW` and starts. `--repo`, `--pr`, and `--sha` are auto-detected —
89
+ see [Works in any CI](#works-in-any-ci) and the PR number is optional: if it
90
+ can't be auto-detected, the server resolves it from the head SHA itself.
42
91
 
43
92
  ```sh
44
93
  datadisco-qa report-preview --url https://pr-482.preview.acme.dev
45
- # explicit outside Actions:
94
+ # explicit, e.g. outside any auto-detected CI:
46
95
  datadisco-qa report-preview \
47
96
  --url https://pr-482.preview.acme.dev \
48
97
  --repo acme/site --pr 482 --sha "$GIT_SHA"
49
98
  ```
50
99
 
100
+ The server debounces rapid pushes for up to 10 minutes before creating the QA
101
+ run row, and CI often reports the preview URL before that row exists. By
102
+ default `report-preview` handles this for you: on the server's "no QA run is
103
+ waiting on a preview URL yet" response it retries every 30s for up to
104
+ **12 minutes**, then fails with a hint if the run never showed up. Any other
105
+ failure (bad token, invalid URL, repo not connected) still fails immediately —
106
+ only the "not created yet" case is retried. The server can also mark an error
107
+ as permanently non-retryable (e.g. the PR is closed, a draft, or the head SHA
108
+ is stale) — those fail immediately too, with the server's error code in the
109
+ message, instead of burning the retry window.
110
+
111
+ | Flag | Effect |
112
+ | ------------------------------ | -------------------------------------------------------- |
113
+ | `--no-wait-for-run` | Fail immediately instead of retrying (previous behavior) |
114
+ | `--wait-run-timeout <minutes>` | Cap the retry window (default 12) |
115
+
51
116
  ### `wait`
52
117
 
53
118
  Block until the PR's QA run reaches a verdict, printing each phase transition.
54
- Polls every 15s; `--timeout` (minutes, default 30) caps the wait.
119
+ Polls every 15s; `--timeout` (minutes, default 30) caps the wait. `--repo` and
120
+ `--pr` are auto-detected the same way as `report-preview`; if the PR number
121
+ isn't detectable, pass `--sha` to poll by head SHA instead.
55
122
 
56
123
  ```sh
57
124
  datadisco-qa wait --pr 482
125
+ # or, when the PR number isn't auto-detected:
126
+ datadisco-qa wait --sha "$GIT_SHA"
58
127
  ```
59
128
 
60
129
  **Exit codes** (so pipelines can branch on the outcome):
@@ -92,8 +161,31 @@ node dist/cli.js --help
92
161
  Same toolchain as [datadisco-mcp](https://github.com/Data-Disco-Inc/datadisco-mcp):
93
162
  tsup, vitest, oxlint/oxfmt, cac.
94
163
 
95
- ## Publishing (later)
164
+ ## Releasing
165
+
166
+ Publishing is automated via npm [trusted publishing](https://docs.npmjs.com/trusted-publishers)
167
+ (OIDC — no token). Cut a release with:
168
+
169
+ ```sh
170
+ npm version <patch|minor|major> # bumps package.json + lockfile, commits, tags vX.Y.Z
171
+ git push origin main --follow-tags
172
+ ```
173
+
174
+ Pushing the `v*` tag runs [`release.yml`](.github/workflows/release.yml), which
175
+ verifies the tag matches `package.json`, runs the tests, and `npm publish`es
176
+ with provenance. Prerelease tags (`vX.Y.Z-beta.1`) publish under the `next`
177
+ dist-tag so `latest` keeps pointing at the newest stable release.
178
+
179
+ ### Lockfile note
180
+
181
+ The toolchain (rolldown/rollup/esbuild) ships platform-native binaries plus a
182
+ wasm fallback whose `@emnapi/*` deps [npm drops from a macOS-generated
183
+ lockfile](https://github.com/npm/cli/issues/4828) — which then fails `npm ci` on
184
+ the Linux CI runner. A plain `npm install` locally is fine for day-to-day work
185
+ (just don't commit the resulting lockfile). If you actually change dependencies,
186
+ regenerate the lockfile on Linux so it stays complete:
96
187
 
97
- 1. Bump the version and tag.
98
- 2. `npm publish` (publishConfig is already `access: public`).
99
- 3. Ship the companion GitHub Action (`Data-Disco-Inc/qa-action`) that wraps this CLI.
188
+ ```sh
189
+ rm -f package-lock.json
190
+ docker run --rm --platform linux/amd64 -v "$PWD":/app -w /app node:20 npm install
191
+ ```
package/dist/cli.js CHANGED
@@ -35,60 +35,137 @@ function assertValidPreviewUrl(url) {
35
35
 
36
36
  // src/context.ts
37
37
  import { readFileSync } from "fs";
38
+
39
+ // src/git.ts
40
+ import { execFileSync } from "child_process";
41
+ var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
42
+ function detectRepoFullName(run2 = defaultRunner) {
43
+ const remote = tryGit(run2, ["remote", "get-url", "origin"]);
44
+ return remote ? parseRepoFromRemote(remote) : void 0;
45
+ }
46
+ function detectHeadSha(run2 = defaultRunner) {
47
+ return tryGit(run2, ["rev-parse", "HEAD"]);
48
+ }
49
+ function parseRepoFromRemote(remote) {
50
+ const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
51
+ return match ? match[1] : void 0;
52
+ }
53
+ function tryGit(run2, args) {
54
+ try {
55
+ return run2(args) || void 0;
56
+ } catch {
57
+ return void 0;
58
+ }
59
+ }
60
+
61
+ // src/context.ts
38
62
  var REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;
39
63
  var PR_REF_PATTERN = /^refs\/pull\/(\d+)\//;
40
- function resolvePrContext(input = {}) {
64
+ var REPO_MISSING_MESSAGE = "Repository not set: pass --repo owner/name (auto-detected from the GitHub Actions env or a github.com origin remote on the git checkout).";
65
+ var HEAD_SHA_MISSING_MESSAGE = "Head SHA not set: pass --sha <sha> (auto-detected from the GitHub Actions env or the git checkout's HEAD).";
66
+ var PR_NUMBER_MISSING_MESSAGE = "Pull request number not set: pass --pr <number> (auto-detected in GitHub Actions, CircleCI, Jenkins, Buildkite, Travis CI, and Drone).";
67
+ var PR_OR_SHA_MISSING_MESSAGE = "Neither a pull request number nor a head SHA could be resolved: pass --pr <number> or --sha <sha> (or run inside a supported CI, or a git checkout).";
68
+ function resolveRunContext(input = {}) {
69
+ const context = resolveRunContextAllowingShaOnly(input);
70
+ if (context.prNumber === void 0) {
71
+ throw new ConfigError(PR_NUMBER_MISSING_MESSAGE);
72
+ }
73
+ return { ...context, prNumber: context.prNumber };
74
+ }
75
+ function resolveRunContextAllowingShaOnly(input = {}) {
41
76
  const env = input.env ?? process.env;
42
77
  const payload = loadEventPayload(input, env);
78
+ const headSha = resolveHeadSha(input, env, payload) ?? detectHeadSha(input.gitRunner);
79
+ if (!headSha) {
80
+ throw new ConfigError(HEAD_SHA_MISSING_MESSAGE);
81
+ }
43
82
  return {
44
83
  repo: resolveRepo(input, env),
45
- prNumber: resolvePrNumber(input, env, payload)
84
+ prNumber: resolvePrNumber(input, env, payload),
85
+ headSha
46
86
  };
47
87
  }
48
- function resolveRunContext(input = {}) {
88
+ function resolvePrContext(input = {}) {
49
89
  const env = input.env ?? process.env;
50
90
  const payload = loadEventPayload(input, env);
51
- return {
52
- repo: resolveRepo(input, env),
53
- prNumber: resolvePrNumber(input, env, payload),
54
- headSha: resolveHeadSha(input, env, payload)
55
- };
91
+ const prNumber = resolvePrNumber(input, env, payload);
92
+ const knownHeadSha = resolveHeadSha(input, env, payload);
93
+ const headSha = prNumber === void 0 ? knownHeadSha ?? detectHeadSha(input.gitRunner) : knownHeadSha;
94
+ if (prNumber === void 0 && !headSha) {
95
+ throw new ConfigError(PR_OR_SHA_MISSING_MESSAGE);
96
+ }
97
+ return { repo: resolveRepo(input, env), prNumber, headSha };
56
98
  }
57
99
  function resolveRepo(input, env = {}) {
58
- const repo = input.repo ?? env.GITHUB_REPOSITORY;
100
+ const repo = input.repo ?? env.GITHUB_REPOSITORY ?? detectRepoFullName(input.gitRunner);
59
101
  if (!repo) {
60
- throw new ConfigError(
61
- "Repository not set: pass --repo owner/name (auto-detected from $GITHUB_REPOSITORY in GitHub Actions)."
62
- );
102
+ throw new ConfigError(REPO_MISSING_MESSAGE);
63
103
  }
64
104
  if (!REPO_PATTERN.test(repo)) {
65
105
  throw new ConfigError(`--repo must look like "owner/name", got "${repo}".`);
66
106
  }
67
107
  return repo;
68
108
  }
109
+ function resolveHeadSha(input, env = {}, payload) {
110
+ return input.sha ?? payload.headSha ?? env.GITHUB_SHA;
111
+ }
69
112
  function resolvePrNumber(input, env = {}, payload) {
70
- const candidate = input.pr ?? parsePrFromRef(env.GITHUB_REF) ?? payload.prNumber;
71
- const prNumber = Number(candidate);
72
- if (candidate === void 0 || !Number.isInteger(prNumber) || prNumber <= 0) {
73
- throw new ConfigError(
74
- "Pull request number not set: pass --pr <number> (auto-detected from the pull_request event in GitHub Actions)."
75
- );
76
- }
77
- return prNumber;
113
+ const candidate = input.pr ?? parsePrFromRef(env.GITHUB_REF) ?? payload.prNumber ?? resolvePrNumberFromCiEnv(env);
114
+ return toPositiveInteger(candidate);
78
115
  }
79
- function resolveHeadSha(input, env = {}, payload) {
80
- const headSha = input.sha ?? payload.headSha ?? env.GITHUB_SHA;
81
- if (!headSha) {
82
- throw new ConfigError(
83
- "Head SHA not set: pass --sha <sha> (the commit the preview was built from)."
84
- );
85
- }
86
- return headSha;
116
+ function toPositiveInteger(candidate) {
117
+ if (candidate === void 0) return void 0;
118
+ const parsed = Number(candidate);
119
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
87
120
  }
88
121
  function parsePrFromRef(ref) {
89
122
  const match = ref?.match(PR_REF_PATTERN);
90
123
  return match ? match[1] : void 0;
91
124
  }
125
+ var CI_PR_ENV_SOURCES = [
126
+ {
127
+ // CircleCI: CIRCLE_PULL_REQUEST is a URL (https://github.com/o/r/pull/123);
128
+ // CIRCLE_PR_NUMBER is a plain number and is checked as a fallback.
129
+ ci: "CircleCI",
130
+ read: (env) => numericPrCandidate(trailingNumber(env.CIRCLE_PULL_REQUEST)) ?? numericPrCandidate(env.CIRCLE_PR_NUMBER)
131
+ },
132
+ {
133
+ // Jenkins (Multibranch/PR builders): CHANGE_ID is the PR number.
134
+ ci: "Jenkins",
135
+ read: (env) => numericPrCandidate(env.CHANGE_ID)
136
+ },
137
+ {
138
+ // Buildkite: BUILDKITE_PULL_REQUEST is "false" on non-PR builds.
139
+ ci: "Buildkite",
140
+ read: (env) => numericPrCandidate(env.BUILDKITE_PULL_REQUEST)
141
+ },
142
+ {
143
+ // Travis CI: TRAVIS_PULL_REQUEST is "false" on non-PR builds.
144
+ ci: "Travis CI",
145
+ read: (env) => numericPrCandidate(env.TRAVIS_PULL_REQUEST)
146
+ },
147
+ {
148
+ // Drone: DRONE_PULL_REQUEST is the PR number, unset outside PR builds.
149
+ ci: "Drone",
150
+ read: (env) => numericPrCandidate(env.DRONE_PULL_REQUEST)
151
+ }
152
+ ];
153
+ function resolvePrNumberFromCiEnv(env) {
154
+ for (const source of CI_PR_ENV_SOURCES) {
155
+ const candidate = source.read(env);
156
+ if (candidate !== void 0) return candidate;
157
+ }
158
+ return void 0;
159
+ }
160
+ function trailingNumber(value) {
161
+ const match = value?.match(/(\d+)\/?$/);
162
+ return match?.[1];
163
+ }
164
+ function numericPrCandidate(value) {
165
+ if (!value || value === "false") return void 0;
166
+ const parsed = Number(value);
167
+ return Number.isInteger(parsed) && parsed > 0 ? value : void 0;
168
+ }
92
169
  function loadEventPayload(input, env = {}) {
93
170
  const read = input.readEventPayload ?? (() => readEventFile(env.GITHUB_EVENT_PATH));
94
171
  const raw = safeRead(read);
@@ -120,6 +197,21 @@ function numberOrUndefined(value) {
120
197
  function stringOrUndefined(value) {
121
198
  return typeof value === "string" ? value : void 0;
122
199
  }
200
+ function describeContextTarget(context) {
201
+ if (context.prNumber !== void 0) return `${context.repo}#${context.prNumber}`;
202
+ if (context.headSha) return `${context.repo}@${context.headSha}`;
203
+ return context.repo;
204
+ }
205
+
206
+ // src/legacy-server.ts
207
+ var LEGACY_SERVER_BAD_REQUEST_STATUS = 400;
208
+ function isUnresolvedPrOnLegacyServer(options) {
209
+ return !options.prNumberKnown && options.status === LEGACY_SERVER_BAD_REQUEST_STATUS && !options.code;
210
+ }
211
+ var LEGACY_SERVER_PR_HINT = "if this is an older DataDisco server, pass --pr <number> explicitly";
212
+ function withLegacyServerPrHint(error) {
213
+ return `${error} (${LEGACY_SERVER_PR_HINT})`;
214
+ }
123
215
 
124
216
  // src/api.ts
125
217
  var REPORT_PREVIEW_PATH = "/api/qa/report-preview";
@@ -148,12 +240,18 @@ async function reportPreview(config, params, fetchImpl) {
148
240
  if (response.ok && isString(payload.runId)) {
149
241
  return { ok: true, runId: payload.runId };
150
242
  }
151
- return { ok: false, status: response.status, error: errorMessage(payload) };
243
+ return {
244
+ ok: false,
245
+ status: response.status,
246
+ error: errorMessage(payload),
247
+ retryable: booleanOrUndefined(payload.retryable),
248
+ code: stringOrUndefined2(payload.code)
249
+ };
152
250
  }
153
251
  async function getRunStatus(config, params, fetchImpl) {
154
252
  const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
155
253
  url.searchParams.set("repo", params.repo);
156
- url.searchParams.set("prNumber", String(params.prNumber));
254
+ setRunLookupParam(url, params);
157
255
  const response = await fetchImpl(url.toString(), {
158
256
  headers: { authorization: `Bearer ${config.apiToken}` }
159
257
  });
@@ -161,7 +259,19 @@ async function getRunStatus(config, params, fetchImpl) {
161
259
  if (response.ok && isRunStatus(payload.run)) {
162
260
  return { ok: true, run: payload.run };
163
261
  }
164
- return { ok: false, status: response.status, error: errorMessage(payload) };
262
+ return {
263
+ ok: false,
264
+ status: response.status,
265
+ error: errorMessage(payload),
266
+ code: stringOrUndefined2(payload.code)
267
+ };
268
+ }
269
+ function setRunLookupParam(url, params) {
270
+ if (params.prNumber !== void 0) {
271
+ url.searchParams.set("prNumber", String(params.prNumber));
272
+ } else if (params.headSha) {
273
+ url.searchParams.set("headSha", params.headSha);
274
+ }
165
275
  }
166
276
  function joinUrl(base, path) {
167
277
  return `${base.replace(/\/+$/, "")}${path}`;
@@ -183,6 +293,12 @@ function isRecord2(value) {
183
293
  function isString(value) {
184
294
  return typeof value === "string";
185
295
  }
296
+ function stringOrUndefined2(value) {
297
+ return isString(value) ? value : void 0;
298
+ }
299
+ function booleanOrUndefined(value) {
300
+ return typeof value === "boolean" ? value : void 0;
301
+ }
186
302
  function isRunStatus(value) {
187
303
  return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
188
304
  }
@@ -212,56 +328,6 @@ var defaultDeps = {
212
328
  now: () => Date.now()
213
329
  };
214
330
 
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
331
  // src/verdict.ts
266
332
  var EXIT_CODE = {
267
333
  PASSED: 0,
@@ -339,7 +405,7 @@ var POLL_INTERVAL_MS = 15e3;
339
405
  var MINUTE_MS = 6e4;
340
406
  async function waitForVerdict(api, context, deps, timeoutMs) {
341
407
  const deadline = deps.now() + timeoutMs;
342
- deps.logger.info(`Waiting for QA verdict on ${context.repo}#${context.prNumber}\u2026`);
408
+ deps.logger.info(`Waiting for QA verdict on ${describeContextTarget(context)}\u2026`);
343
409
  let lastStatus;
344
410
  while (true) {
345
411
  const outcome = await api.getRunStatus(context);
@@ -354,7 +420,9 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
354
420
  return verdict.exitCode;
355
421
  }
356
422
  } else if (outcome.status !== 404) {
357
- deps.logger.error(`Could not read QA run status: ${outcome.error}`);
423
+ deps.logger.error(
424
+ `Could not read QA run status: ${describeRunStatusFailure(outcome, context)}`
425
+ );
358
426
  return 1;
359
427
  }
360
428
  if (deps.now() >= deadline) {
@@ -366,11 +434,25 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
366
434
  await deps.sleep(POLL_INTERVAL_MS);
367
435
  }
368
436
  }
369
- function resolveTimeoutMs(timeout) {
370
- if (timeout === void 0) return DEFAULT_TIMEOUT_MINUTES * MINUTE_MS;
437
+ function describeRunStatusFailure(outcome, context) {
438
+ if (isUnresolvedPrOnLegacyServer({
439
+ prNumberKnown: context.prNumber !== void 0,
440
+ status: outcome.status,
441
+ code: outcome.code
442
+ })) {
443
+ return withLegacyServerPrHint(outcome.error);
444
+ }
445
+ return outcome.error;
446
+ }
447
+ var DEFAULT_TIMEOUT_FLAG = {
448
+ flag: "--timeout",
449
+ defaultMinutes: DEFAULT_TIMEOUT_MINUTES
450
+ };
451
+ function resolveTimeoutMs(timeout, spec = DEFAULT_TIMEOUT_FLAG) {
452
+ if (timeout === void 0) return spec.defaultMinutes * MINUTE_MS;
371
453
  const minutes = Number(timeout);
372
454
  if (!Number.isFinite(minutes) || minutes <= 0) {
373
- throw new ConfigError(`--timeout must be a positive number of minutes, got "${timeout}".`);
455
+ throw new ConfigError(`${spec.flag} must be a positive number of minutes, got "${timeout}".`);
374
456
  }
375
457
  return minutes * MINUTE_MS;
376
458
  }
@@ -378,6 +460,86 @@ function statusLine(run2) {
378
460
  return run2.statusMessage ? `${run2.status} \u2014 ${run2.statusMessage}` : run2.status;
379
461
  }
380
462
 
463
+ // src/commands/report-preview.ts
464
+ var RUN_NOT_CREATED_YET_STATUS = 409;
465
+ var RETRY_INTERVAL_MS = 3e4;
466
+ var DEFAULT_WAIT_RUN_TIMEOUT_MINUTES = 12;
467
+ var WAIT_RUN_TIMEOUT_SPEC = {
468
+ flag: "--wait-run-timeout",
469
+ defaultMinutes: DEFAULT_WAIT_RUN_TIMEOUT_MINUTES
470
+ };
471
+ async function runReportPreview(options, deps = defaultDeps) {
472
+ if (!options.url) {
473
+ deps.logger.error("--url is required (the deployed preview URL).");
474
+ return 1;
475
+ }
476
+ assertValidPreviewUrl(options.url);
477
+ const config = resolveConfig(options);
478
+ const context = resolveRunContextAllowingShaOnly(options);
479
+ const waitRunTimeoutMs = resolveTimeoutMs(options.waitRunTimeout, WAIT_RUN_TIMEOUT_SPEC);
480
+ const api = deps.createApi(config);
481
+ const outcome = await reportPreviewUntilRunExists(api, context, options.url, deps, {
482
+ waitForRun: options.waitForRun !== false,
483
+ waitRunTimeoutMs
484
+ });
485
+ if (!outcome.ok) {
486
+ deps.logger.error(`Could not report preview URL: ${describeFailure(outcome, context)}`);
487
+ return 1;
488
+ }
489
+ deps.logger.success(
490
+ `Preview reported for ${describeContextTarget(context)} \u2014 QA run ${outcome.runId} queued.`
491
+ );
492
+ return 0;
493
+ }
494
+ async function reportPreviewUntilRunExists(api, context, url, deps, policy) {
495
+ const firstAttempt = await api.reportPreview({ ...context, url });
496
+ if (firstAttempt.ok || !isRunNotCreatedYet(firstAttempt) || !policy.waitForRun) {
497
+ return firstAttempt;
498
+ }
499
+ return retryReportPreviewUntilRunExists(api, context, url, deps, policy.waitRunTimeoutMs);
500
+ }
501
+ function isRunNotCreatedYet(outcome) {
502
+ return !outcome.ok && outcome.status === RUN_NOT_CREATED_YET_STATUS && outcome.retryable !== false;
503
+ }
504
+ function describeFailure(outcome, context) {
505
+ if (isUnresolvedPrOnLegacyServer({
506
+ prNumberKnown: context.prNumber !== void 0,
507
+ status: outcome.status,
508
+ code: outcome.code
509
+ })) {
510
+ return withLegacyServerPrHint(outcome.error);
511
+ }
512
+ return outcome.code ? `${outcome.error} [${outcome.code}]` : outcome.error;
513
+ }
514
+ async function retryReportPreviewUntilRunExists(api, context, url, deps, waitRunTimeoutMs) {
515
+ const deadline = deps.now() + waitRunTimeoutMs;
516
+ deps.logger.info(
517
+ `QA run not created yet \u2014 the server may still be debouncing this push. Retrying for up to ${formatMinutes(waitRunTimeoutMs)} min\u2026`
518
+ );
519
+ while (true) {
520
+ await deps.sleep(Math.min(RETRY_INTERVAL_MS, deadline - deps.now()));
521
+ const outcome = await api.reportPreview({ ...context, url });
522
+ if (outcome.ok || !isRunNotCreatedYet(outcome)) return outcome;
523
+ if (deps.now() >= deadline) {
524
+ return { ...outcome, error: withRunNeverAppearedHint(outcome.error) };
525
+ }
526
+ deps.logger.info("Still waiting for the QA run to be created\u2026");
527
+ }
528
+ }
529
+ function formatMinutes(ms) {
530
+ return Math.round(ms / MINUTE_MS * 100) / 100;
531
+ }
532
+ function withRunNeverAppearedHint(error) {
533
+ return `${error} (gave up waiting \u2014 the QA run never appeared)`;
534
+ }
535
+
536
+ // src/tunnel-provider.ts
537
+ import localtunnel from "localtunnel";
538
+ async function openTunnel(port) {
539
+ const tunnel = await localtunnel({ port });
540
+ return { url: tunnel.url, close: () => tunnel.close() };
541
+ }
542
+
381
543
  // src/commands/tunnel.ts
382
544
  var defaultTunnelDeps = {
383
545
  ...defaultDeps,
@@ -437,8 +599,14 @@ async function runWait(options, deps = defaultDeps) {
437
599
 
438
600
  // src/cli.ts
439
601
  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)").action((options) => run(() => runReportPreview(options)));
441
- 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)));
602
+ 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(
603
+ "--no-wait-for-run",
604
+ "Fail immediately if the QA run hasn't been created yet, instead of retrying"
605
+ ).option(
606
+ "--wait-run-timeout <minutes>",
607
+ "How long to retry while the QA run hasn't been created yet (default 12)"
608
+ ).action((options) => run(() => runReportPreview(options)));
609
+ 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("--sha <sha>", "Head SHA to look up the run by, if the PR number isn't known").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
610
  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
611
  async function run(command) {
444
612
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datadisco/qa",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "CI companion for DataDisco QA — report preview URLs, wait on persona-simulation gates, and tunnel local builds into QA runs.",
5
5
  "keywords": [
6
6
  "ci",