@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.
- package/README.md +103 -11
- package/dist/cli.js +256 -88
- 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
|
|
|
@@ -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.
|
|
41
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
88
|
+
function resolvePrContext(input = {}) {
|
|
49
89
|
const env = input.env ?? process.env;
|
|
50
90
|
const payload = loadEventPayload(input, env);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
370
|
-
if (
|
|
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(
|
|
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)").
|
|
441
|
-
|
|
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