@datadisco/qa 0.2.0 → 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 +51 -4
  2. package/dist/cli.js +160 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,17 +38,60 @@ to the hosted app and is overridable with `--api-url` / `DATADISCO_API_URL`.
38
38
  export DATADISCO_API_TOKEN=ddqa_xxx
39
39
  ```
40
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
+
41
83
  ## Commands
42
84
 
43
85
  ### `report-preview`
44
86
 
45
87
  Report the deployed preview URL for a PR head SHA so its pending QA run leaves
46
- `PENDING_PREVIEW` and starts. In GitHub Actions, `--repo`, `--pr`, and `--sha`
47
- 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.
48
91
 
49
92
  ```sh
50
93
  datadisco-qa report-preview --url https://pr-482.preview.acme.dev
51
- # explicit outside Actions:
94
+ # explicit, e.g. outside any auto-detected CI:
52
95
  datadisco-qa report-preview \
53
96
  --url https://pr-482.preview.acme.dev \
54
97
  --repo acme/site --pr 482 --sha "$GIT_SHA"
@@ -73,10 +116,14 @@ message, instead of burning the retry window.
73
116
  ### `wait`
74
117
 
75
118
  Block until the PR's QA run reaches a verdict, printing each phase transition.
76
- 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.
77
122
 
78
123
  ```sh
79
124
  datadisco-qa wait --pr 482
125
+ # or, when the PR number isn't auto-detected:
126
+ datadisco-qa wait --sha "$GIT_SHA"
80
127
  ```
81
128
 
82
129
  **Exit codes** (so pipelines can branch on the outcome):
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";
@@ -159,7 +251,7 @@ async function reportPreview(config, params, fetchImpl) {
159
251
  async function getRunStatus(config, params, fetchImpl) {
160
252
  const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
161
253
  url.searchParams.set("repo", params.repo);
162
- url.searchParams.set("prNumber", String(params.prNumber));
254
+ setRunLookupParam(url, params);
163
255
  const response = await fetchImpl(url.toString(), {
164
256
  headers: { authorization: `Bearer ${config.apiToken}` }
165
257
  });
@@ -167,7 +259,19 @@ async function getRunStatus(config, params, fetchImpl) {
167
259
  if (response.ok && isRunStatus(payload.run)) {
168
260
  return { ok: true, run: payload.run };
169
261
  }
170
- 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
+ }
171
275
  }
172
276
  function joinUrl(base, path) {
173
277
  return `${base.replace(/\/+$/, "")}${path}`;
@@ -301,7 +405,7 @@ var POLL_INTERVAL_MS = 15e3;
301
405
  var MINUTE_MS = 6e4;
302
406
  async function waitForVerdict(api, context, deps, timeoutMs) {
303
407
  const deadline = deps.now() + timeoutMs;
304
- 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`);
305
409
  let lastStatus;
306
410
  while (true) {
307
411
  const outcome = await api.getRunStatus(context);
@@ -316,7 +420,9 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
316
420
  return verdict.exitCode;
317
421
  }
318
422
  } else if (outcome.status !== 404) {
319
- 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
+ );
320
426
  return 1;
321
427
  }
322
428
  if (deps.now() >= deadline) {
@@ -328,6 +434,16 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
328
434
  await deps.sleep(POLL_INTERVAL_MS);
329
435
  }
330
436
  }
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
+ }
331
447
  var DEFAULT_TIMEOUT_FLAG = {
332
448
  flag: "--timeout",
333
449
  defaultMinutes: DEFAULT_TIMEOUT_MINUTES
@@ -359,7 +475,7 @@ async function runReportPreview(options, deps = defaultDeps) {
359
475
  }
360
476
  assertValidPreviewUrl(options.url);
361
477
  const config = resolveConfig(options);
362
- const context = resolveRunContext(options);
478
+ const context = resolveRunContextAllowingShaOnly(options);
363
479
  const waitRunTimeoutMs = resolveTimeoutMs(options.waitRunTimeout, WAIT_RUN_TIMEOUT_SPEC);
364
480
  const api = deps.createApi(config);
365
481
  const outcome = await reportPreviewUntilRunExists(api, context, options.url, deps, {
@@ -367,11 +483,11 @@ async function runReportPreview(options, deps = defaultDeps) {
367
483
  waitRunTimeoutMs
368
484
  });
369
485
  if (!outcome.ok) {
370
- deps.logger.error(`Could not report preview URL: ${describeFailure(outcome)}`);
486
+ deps.logger.error(`Could not report preview URL: ${describeFailure(outcome, context)}`);
371
487
  return 1;
372
488
  }
373
489
  deps.logger.success(
374
- `Preview reported for ${context.repo}#${context.prNumber} \u2014 QA run ${outcome.runId} queued.`
490
+ `Preview reported for ${describeContextTarget(context)} \u2014 QA run ${outcome.runId} queued.`
375
491
  );
376
492
  return 0;
377
493
  }
@@ -385,7 +501,14 @@ async function reportPreviewUntilRunExists(api, context, url, deps, policy) {
385
501
  function isRunNotCreatedYet(outcome) {
386
502
  return !outcome.ok && outcome.status === RUN_NOT_CREATED_YET_STATUS && outcome.retryable !== false;
387
503
  }
388
- function describeFailure(outcome) {
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
+ }
389
512
  return outcome.code ? `${outcome.error} [${outcome.code}]` : outcome.error;
390
513
  }
391
514
  async function retryReportPreviewUntilRunExists(api, context, url, deps, waitRunTimeoutMs) {
@@ -410,28 +533,6 @@ function withRunNeverAppearedHint(error) {
410
533
  return `${error} (gave up waiting \u2014 the QA run never appeared)`;
411
534
  }
412
535
 
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
536
  // src/tunnel-provider.ts
436
537
  import localtunnel from "localtunnel";
437
538
  async function openTunnel(port) {
@@ -505,7 +606,7 @@ cli.command("report-preview", "Report a PR's preview-deploy URL so its pending Q
505
606
  "--wait-run-timeout <minutes>",
506
607
  "How long to retry while the QA run hasn't been created yet (default 12)"
507
608
  ).action((options) => run(() => runReportPreview(options)));
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)));
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)));
509
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)));
510
611
  async function run(command) {
511
612
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datadisco/qa",
3
- "version": "0.2.0",
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",