@datadisco/qa 0.2.0 → 0.4.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 +172 -10
- package/dist/cli.js +1323 -110
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,16 +10,24 @@ var API_TOKEN_ENV_KEY = "DATADISCO_API_TOKEN";
|
|
|
10
10
|
var ConfigError = class extends Error {
|
|
11
11
|
};
|
|
12
12
|
function resolveConfig(input = {}) {
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const apiToken = input.apiToken || env[API_TOKEN_ENV_KEY];
|
|
13
|
+
const apiUrl = resolveApiUrl(input);
|
|
14
|
+
const apiToken = resolveExplicitApiToken(input);
|
|
16
15
|
if (!apiToken) {
|
|
17
16
|
throw new ConfigError(`Missing API token: pass --api-token or set ${API_TOKEN_ENV_KEY}.`);
|
|
18
17
|
}
|
|
18
|
+
return { apiUrl, apiToken };
|
|
19
|
+
}
|
|
20
|
+
function resolveApiUrl(input = {}) {
|
|
21
|
+
const env = input.env ?? process.env;
|
|
22
|
+
const apiUrl = input.apiUrl || env.DATADISCO_API_URL || DEFAULT_API_URL;
|
|
19
23
|
if (!apiUrl.startsWith("https://")) {
|
|
20
24
|
throw new ConfigError(`API url must be https, got "${apiUrl}".`);
|
|
21
25
|
}
|
|
22
|
-
return
|
|
26
|
+
return apiUrl;
|
|
27
|
+
}
|
|
28
|
+
function resolveExplicitApiToken(input = {}) {
|
|
29
|
+
const env = input.env ?? process.env;
|
|
30
|
+
return input.apiToken || env[API_TOKEN_ENV_KEY] || void 0;
|
|
23
31
|
}
|
|
24
32
|
function assertValidPreviewUrl(url) {
|
|
25
33
|
let parsed;
|
|
@@ -33,110 +41,562 @@ function assertValidPreviewUrl(url) {
|
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
// src/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
44
|
+
// src/connection.ts
|
|
45
|
+
function findGithubConnection(outcome) {
|
|
46
|
+
if (!outcome.ok) return void 0;
|
|
47
|
+
return outcome.connections.find((connection) => connection.kind === "GITHUB");
|
|
48
|
+
}
|
|
49
|
+
function connectRepoUrl(apiUrl, repo) {
|
|
50
|
+
const url = new URL("/qa/dance", apiUrl);
|
|
51
|
+
url.searchParams.set("repo", repo);
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
54
|
+
function describeConnectionProblem(connection) {
|
|
55
|
+
if (!connection.hasInstallation) {
|
|
56
|
+
return "the DataDisco GitHub App is not installed on this repo \u2014 reinstall it from the connection page";
|
|
57
|
+
}
|
|
58
|
+
if (!connection.enabled) return "the connection is disabled \u2014 enable it from the connection page";
|
|
59
|
+
if (connection.status !== "ACTIVE") {
|
|
60
|
+
return `the connection status is ${connection.status} \u2014 check the connection page`;
|
|
61
|
+
}
|
|
62
|
+
return void 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/gh.ts
|
|
66
|
+
function checkGh(run2) {
|
|
67
|
+
if (!run2("gh", ["--version"]).ok) return "missing";
|
|
68
|
+
return run2("gh", ["auth", "status"]).ok ? "ready" : "unauthenticated";
|
|
69
|
+
}
|
|
70
|
+
function setRepoSecret(run2, repo, name, value) {
|
|
71
|
+
return run2("gh", ["secret", "set", name, "--repo", repo], value);
|
|
72
|
+
}
|
|
73
|
+
function listRepoSecretNames(run2, repo) {
|
|
74
|
+
const result = run2("gh", [
|
|
75
|
+
"secret",
|
|
76
|
+
"list",
|
|
77
|
+
"--repo",
|
|
78
|
+
repo,
|
|
79
|
+
"--json",
|
|
80
|
+
"name",
|
|
81
|
+
"--jq",
|
|
82
|
+
".[].name"
|
|
83
|
+
]);
|
|
84
|
+
if (!result.ok) return void 0;
|
|
85
|
+
return result.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/host-detection.ts
|
|
89
|
+
import { join } from "path";
|
|
90
|
+
var DEPLOY_EVENT_HOSTS = /* @__PURE__ */ new Set(["vercel", "netlify", "amplify"]);
|
|
91
|
+
var HOST_MARKERS = [
|
|
92
|
+
{ host: "vercel", path: "vercel.json" },
|
|
93
|
+
{ host: "vercel", path: ".vercel" },
|
|
94
|
+
{ host: "netlify", path: "netlify.toml" },
|
|
95
|
+
{ host: "amplify", path: "amplify.yml" },
|
|
96
|
+
{ host: "fly", path: "fly.toml" },
|
|
97
|
+
{ host: "render", path: "render.yaml" },
|
|
98
|
+
{ host: "railway", path: "railway.json" },
|
|
99
|
+
{ host: "railway", path: "railway.toml" }
|
|
100
|
+
];
|
|
101
|
+
var WORKFLOW_KEYWORDS = [
|
|
102
|
+
{ host: "vercel", pattern: /\bvercel\b/i, label: "vercel" },
|
|
103
|
+
{ host: "netlify", pattern: /\bnetlify\b/i, label: "netlify" },
|
|
104
|
+
{ host: "amplify", pattern: /\bamplify\b/i, label: "amplify" },
|
|
105
|
+
{ host: "fly", pattern: /\bfly(?:ctl|\.io)?\b|superfly/i, label: "fly" },
|
|
106
|
+
{ host: "render", pattern: /\brender(?:\.com)?\b/i, label: "render" },
|
|
107
|
+
{ host: "railway", pattern: /\brailway\b/i, label: "railway" }
|
|
108
|
+
];
|
|
109
|
+
var GENERIC_DEPLOY_PATTERN = /deploy/i;
|
|
110
|
+
var WORKFLOWS_DIR = join(".github", "workflows");
|
|
111
|
+
var WORKFLOW_FILE_PATTERN = /\.ya?ml$/;
|
|
112
|
+
function isDeployEventHost(host) {
|
|
113
|
+
return DEPLOY_EVENT_HOSTS.has(host);
|
|
114
|
+
}
|
|
115
|
+
function detectDeployHost(repoRoot, files) {
|
|
116
|
+
const markerHits = findMarkerFiles(repoRoot, files);
|
|
117
|
+
const workflowHits2 = scanWorkflows(repoRoot, files);
|
|
118
|
+
const hosts = [...markerHits, ...workflowHits2].map((hit) => hit.host);
|
|
119
|
+
const host = hosts.find((candidate) => candidate !== void 0) ?? "unknown";
|
|
43
120
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
121
|
+
host,
|
|
122
|
+
evidence: [...markerHits, ...workflowHits2].map((hit) => hit.evidence),
|
|
123
|
+
// A workflow naming Vercel/Netlify/Amplify deploys through their CLI, which creates no
|
|
124
|
+
// GitHub Deployments — only the marker files point at the Git integration.
|
|
125
|
+
deployEvents: namesDeployEventHost(markerHits) && !namesDeployEventHost(workflowHits2)
|
|
46
126
|
};
|
|
47
127
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
128
|
+
function namesDeployEventHost(hits) {
|
|
129
|
+
return hits.some((hit) => hit.host !== void 0 && DEPLOY_EVENT_HOSTS.has(hit.host));
|
|
130
|
+
}
|
|
131
|
+
function findMarkerFiles(repoRoot, files) {
|
|
132
|
+
return HOST_MARKERS.filter((marker) => files.exists(join(repoRoot, marker.path))).map(
|
|
133
|
+
(marker) => ({ host: marker.host, evidence: marker.path })
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
function scanWorkflows(repoRoot, files) {
|
|
137
|
+
const workflowsDir = join(repoRoot, WORKFLOWS_DIR);
|
|
138
|
+
const workflowFiles = files.listDir(workflowsDir).filter((name) => WORKFLOW_FILE_PATTERN.test(name));
|
|
139
|
+
return workflowFiles.flatMap((name) => {
|
|
140
|
+
const content = files.readFile(join(workflowsDir, name)) ?? "";
|
|
141
|
+
return workflowHits(`${WORKFLOWS_DIR}/${name}`, content);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function workflowHits(workflowPath, content) {
|
|
145
|
+
const hostHits = WORKFLOW_KEYWORDS.filter((keyword) => keyword.pattern.test(content)).map(
|
|
146
|
+
(keyword) => ({ host: keyword.host, evidence: describeWorkflowHit(workflowPath, keyword) })
|
|
147
|
+
);
|
|
148
|
+
if (hostHits.length === 0 && GENERIC_DEPLOY_PATTERN.test(content)) {
|
|
149
|
+
return [{ host: void 0, evidence: `${workflowPath} mentions deploy` }];
|
|
150
|
+
}
|
|
151
|
+
return hostHits;
|
|
152
|
+
}
|
|
153
|
+
function describeWorkflowHit(workflowPath, keyword) {
|
|
154
|
+
const suffix = DEPLOY_EVENT_HOSTS.has(keyword.host) ? " (deploys from CI?)" : "";
|
|
155
|
+
return `${workflowPath} mentions ${keyword.label}${suffix}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/config-file.ts
|
|
159
|
+
import { homedir } from "os";
|
|
160
|
+
import { join as join2 } from "path";
|
|
161
|
+
var CONFIG_DIR_NAME = "datadisco";
|
|
162
|
+
var CONFIG_FILE_NAME = "qa.json";
|
|
163
|
+
var OWNER_ONLY_FILE_MODE = 384;
|
|
164
|
+
var OWNER_ONLY_DIR_MODE = 448;
|
|
165
|
+
function resolveConfigFilePath(env = process.env, home = homedir()) {
|
|
166
|
+
const configHome = env.XDG_CONFIG_HOME || join2(home, ".config");
|
|
167
|
+
return join2(configHome, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
|
168
|
+
}
|
|
169
|
+
function readStoredToken(apiUrl, deps) {
|
|
170
|
+
return readTokens(deps)[apiUrl];
|
|
171
|
+
}
|
|
172
|
+
function saveStoredToken(apiUrl, approval, deps) {
|
|
173
|
+
const tokens = readTokens(deps);
|
|
174
|
+
tokens[apiUrl] = {
|
|
175
|
+
token: approval.apiToken,
|
|
176
|
+
workspaceName: approval.workspaceName,
|
|
177
|
+
workspaceSlug: approval.workspaceSlug,
|
|
178
|
+
savedAt: new Date(deps.now()).toISOString()
|
|
179
|
+
};
|
|
180
|
+
writeTokens(tokens, deps);
|
|
181
|
+
}
|
|
182
|
+
function deleteStoredToken(apiUrl, deps) {
|
|
183
|
+
const tokens = readTokens(deps);
|
|
184
|
+
if (!(apiUrl in tokens)) return false;
|
|
185
|
+
delete tokens[apiUrl];
|
|
186
|
+
writeTokens(tokens, deps);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function readTokens(deps) {
|
|
190
|
+
const raw = deps.readFile(deps.configFilePath);
|
|
191
|
+
if (!raw) return {};
|
|
192
|
+
const parsed = parseJson(raw);
|
|
193
|
+
if (!isRecord(parsed) || !isRecord(parsed.tokens)) return {};
|
|
194
|
+
return collectStoredTokens(parsed.tokens);
|
|
195
|
+
}
|
|
196
|
+
function writeTokens(tokens, deps) {
|
|
197
|
+
const content = `${JSON.stringify({ tokens }, null, 2)}
|
|
198
|
+
`;
|
|
199
|
+
deps.writeFile(deps.configFilePath, content, {
|
|
200
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
201
|
+
dirMode: OWNER_ONLY_DIR_MODE
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function collectStoredTokens(value) {
|
|
205
|
+
const tokens = {};
|
|
206
|
+
for (const [apiUrl, entry] of Object.entries(value)) {
|
|
207
|
+
const stored = parseStoredToken(entry);
|
|
208
|
+
if (stored) tokens[apiUrl] = stored;
|
|
209
|
+
}
|
|
210
|
+
return tokens;
|
|
211
|
+
}
|
|
212
|
+
function parseStoredToken(value) {
|
|
213
|
+
if (!isRecord(value) || typeof value.token !== "string") return void 0;
|
|
51
214
|
return {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
215
|
+
token: value.token,
|
|
216
|
+
workspaceName: stringOr(value.workspaceName, ""),
|
|
217
|
+
workspaceSlug: stringOr(value.workspaceSlug, ""),
|
|
218
|
+
savedAt: stringOr(value.savedAt, "")
|
|
55
219
|
};
|
|
56
220
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
221
|
+
function parseJson(raw) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return void 0;
|
|
63
226
|
}
|
|
64
|
-
|
|
65
|
-
|
|
227
|
+
}
|
|
228
|
+
function isRecord(value) {
|
|
229
|
+
return typeof value === "object" && value !== null;
|
|
230
|
+
}
|
|
231
|
+
function stringOr(value, fallback) {
|
|
232
|
+
return typeof value === "string" ? value : fallback;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/pairing.ts
|
|
236
|
+
var SECOND_MS = 1e3;
|
|
237
|
+
var MIN_POLL_INTERVAL_MS = SECOND_MS;
|
|
238
|
+
var MAX_BACKOFF_INTERVAL_MS = 30 * SECOND_MS;
|
|
239
|
+
var TOO_MANY_REQUESTS = 429;
|
|
240
|
+
var SERVER_ERROR = 500;
|
|
241
|
+
var NO_RESPONSE = 0;
|
|
242
|
+
async function pairDevice(api, deps, options) {
|
|
243
|
+
const started = await api.pair(deps.deviceName());
|
|
244
|
+
if (!started.ok) {
|
|
245
|
+
return { ok: false, error: describePairFailure(started.error, started.code) };
|
|
66
246
|
}
|
|
67
|
-
|
|
247
|
+
await announcePairing(started.pairing, deps, options);
|
|
248
|
+
return pollUntilResolved(api, started.pairing, deps);
|
|
68
249
|
}
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
250
|
+
async function signIn(apiUrl, api, deps, options) {
|
|
251
|
+
const result = await pairDevice(api, deps, options);
|
|
252
|
+
if (!result.ok) return result;
|
|
253
|
+
saveStoredToken(apiUrl, result.approval, deps);
|
|
254
|
+
deps.logger.success(`Signed in to workspace ${result.approval.workspaceName}`);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
async function announcePairing(pairing, deps, options) {
|
|
258
|
+
deps.logger.info(`Open ${pairing.verificationUrl} and confirm code ${pairing.userCode}`);
|
|
259
|
+
if (options.openBrowser) {
|
|
260
|
+
await deps.openBrowser(pairing.verificationUrl);
|
|
76
261
|
}
|
|
77
|
-
return prNumber;
|
|
78
262
|
}
|
|
79
|
-
function
|
|
80
|
-
const
|
|
81
|
-
|
|
263
|
+
async function pollUntilResolved(api, pairing, deps) {
|
|
264
|
+
const deadline = deps.now() + pairing.expiresInSeconds * SECOND_MS;
|
|
265
|
+
let interval = Math.max(pairing.intervalSeconds * SECOND_MS, MIN_POLL_INTERVAL_MS);
|
|
266
|
+
let lastTransientError;
|
|
267
|
+
while (true) {
|
|
268
|
+
await deps.sleep(interval);
|
|
269
|
+
const outcome = await pollOnce(api, pairing.deviceCode);
|
|
270
|
+
if (isTransientFailure(outcome)) {
|
|
271
|
+
lastTransientError = outcome.error;
|
|
272
|
+
if (isRateLimited(outcome)) interval = Math.min(interval * 2, MAX_BACKOFF_INTERVAL_MS);
|
|
273
|
+
} else {
|
|
274
|
+
const resolved = resolvePoll(outcome);
|
|
275
|
+
if (resolved) return resolved;
|
|
276
|
+
lastTransientError = void 0;
|
|
277
|
+
}
|
|
278
|
+
if (deps.now() >= deadline) {
|
|
279
|
+
const detail = lastTransientError ? ` (last error: ${lastTransientError})` : "";
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
error: `Pairing code expired before it was confirmed. Try again.${detail}`
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function pollOnce(api, deviceCode) {
|
|
288
|
+
try {
|
|
289
|
+
return await api.pollPairing(deviceCode);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
+
return { ok: false, status: NO_RESPONSE, error: message };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function isTransientFailure(outcome) {
|
|
296
|
+
if (outcome.ok) return false;
|
|
297
|
+
if (outcome.code && outcome.code in POLL_FAILURE_MESSAGES) return false;
|
|
298
|
+
return outcome.status === NO_RESPONSE || outcome.status >= SERVER_ERROR || isRateLimited(outcome);
|
|
299
|
+
}
|
|
300
|
+
function isRateLimited(outcome) {
|
|
301
|
+
return !outcome.ok && (outcome.status === TOO_MANY_REQUESTS || outcome.code === "RATE_LIMITED");
|
|
302
|
+
}
|
|
303
|
+
function resolvePoll(outcome) {
|
|
304
|
+
if (!outcome.ok) {
|
|
305
|
+
return { ok: false, error: describePollFailure(outcome.error, outcome.code) };
|
|
306
|
+
}
|
|
307
|
+
return outcome.pending ? void 0 : { ok: true, approval: outcome.approval };
|
|
308
|
+
}
|
|
309
|
+
function describePairFailure(error, code) {
|
|
310
|
+
if (code === "RATE_LIMITED") {
|
|
311
|
+
return `Too many sign-in attempts \u2014 wait a minute and try again. (${error})`;
|
|
312
|
+
}
|
|
313
|
+
return `Could not start sign-in: ${error}`;
|
|
314
|
+
}
|
|
315
|
+
var POLL_FAILURE_MESSAGES = {
|
|
316
|
+
DENIED: "Sign-in was denied in the browser.",
|
|
317
|
+
EXPIRED: "Pairing code expired before it was confirmed. Try again.",
|
|
318
|
+
ALREADY_CLAIMED: "This pairing code was already used. Run the command again for a fresh code.",
|
|
319
|
+
NOT_FOUND: "The server no longer recognizes this pairing code. Run the command again."
|
|
320
|
+
};
|
|
321
|
+
function describePollFailure(error, code) {
|
|
322
|
+
const known = code ? POLL_FAILURE_MESSAGES[code] : void 0;
|
|
323
|
+
return known ?? `Sign-in failed: ${error}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/session.ts
|
|
327
|
+
function findExistingToken(options, apiUrl, deps) {
|
|
328
|
+
return resolveExplicitApiToken(options) ?? readStoredToken(apiUrl, deps)?.token;
|
|
329
|
+
}
|
|
330
|
+
async function resolveSession(options, deps) {
|
|
331
|
+
const apiUrl = resolveApiUrl(options);
|
|
332
|
+
const existing = findExistingToken(options, apiUrl, deps);
|
|
333
|
+
if (existing) {
|
|
334
|
+
deps.logger.info(describeTokenSource(options, apiUrl, deps));
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
session: { apiUrl, apiToken: existing, api: deps.createApi({ apiUrl, apiToken: existing }) }
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
deps.logger.info("No saved sign-in for this API \u2014 pairing this device\u2026");
|
|
341
|
+
const result = await signIn(apiUrl, deps.createApi({ apiUrl }), deps, {
|
|
342
|
+
openBrowser: options.browser !== false
|
|
343
|
+
});
|
|
344
|
+
if (!result.ok) return result;
|
|
345
|
+
const apiToken = result.approval.apiToken;
|
|
346
|
+
return { ok: true, session: { apiUrl, apiToken, api: deps.createApi({ apiUrl, apiToken }) } };
|
|
347
|
+
}
|
|
348
|
+
function describeTokenSource(options, apiUrl, deps) {
|
|
349
|
+
if (resolveExplicitApiToken(options)) return "Using the API token from the flag/environment.";
|
|
350
|
+
const stored = readStoredToken(apiUrl, deps);
|
|
351
|
+
return stored?.workspaceName ? `Using the saved sign-in for workspace ${stored.workspaceName}.` : "Using the saved sign-in.";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/workflow-recipe.ts
|
|
355
|
+
import { join as join3 } from "path";
|
|
356
|
+
var WORKFLOW_RELATIVE_PATH = join3(".github", "workflows", "datadisco-qa.yml");
|
|
357
|
+
var API_TOKEN_SECRET_NAME = "DATADISCO_API_TOKEN";
|
|
358
|
+
var PATTERN_PLACEHOLDER = "{number}";
|
|
359
|
+
var ACTION_REF = "Data-Disco-Inc/qa-action@v1";
|
|
360
|
+
var PR_NUMBER_EXPRESSION = "${{ github.event.pull_request.number }}";
|
|
361
|
+
var SECRET_EXPRESSION = `\${{ secrets.${API_TOKEN_SECRET_NAME} }}`;
|
|
362
|
+
var DEPLOYMENT_STATUS_TRIGGER = /^\s*deployment_status\s*:/m;
|
|
363
|
+
function workflowFilePath(repoRoot) {
|
|
364
|
+
return join3(repoRoot, WORKFLOW_RELATIVE_PATH);
|
|
365
|
+
}
|
|
366
|
+
function assertValidPreviewUrlPattern(pattern) {
|
|
367
|
+
if (!pattern.startsWith("https://")) {
|
|
368
|
+
throw new ConfigError(`Preview URL pattern must start with https://, got "${pattern}".`);
|
|
369
|
+
}
|
|
370
|
+
if (!pattern.includes(PATTERN_PLACEHOLDER)) {
|
|
82
371
|
throw new ConfigError(
|
|
83
|
-
|
|
372
|
+
`Preview URL pattern must contain ${PATTERN_PLACEHOLDER}, e.g. https://pr-${PATTERN_PLACEHOLDER}.preview.example.com`
|
|
84
373
|
);
|
|
85
374
|
}
|
|
86
|
-
return headSha;
|
|
87
375
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
return match ? match[1] : void 0;
|
|
376
|
+
function describeRecipe(pattern) {
|
|
377
|
+
return `report-preview on pull_request using ${pattern}`;
|
|
91
378
|
}
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
const raw = safeRead(read);
|
|
95
|
-
if (!isRecord(raw)) return {};
|
|
96
|
-
const pullRequest = isRecord(raw.pull_request) ? raw.pull_request : void 0;
|
|
97
|
-
const head = isRecord(pullRequest?.head) ? pullRequest.head : void 0;
|
|
98
|
-
return {
|
|
99
|
-
prNumber: numberOrUndefined(pullRequest?.number ?? raw.number),
|
|
100
|
-
headSha: stringOrUndefined(head?.sha)
|
|
101
|
-
};
|
|
379
|
+
function triggersOnDeploymentStatus(workflow) {
|
|
380
|
+
return DEPLOYMENT_STATUS_TRIGGER.test(workflow);
|
|
102
381
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
return
|
|
382
|
+
function renderWorkflow(pattern) {
|
|
383
|
+
const url = pattern.split(PATTERN_PLACEHOLDER).join(PR_NUMBER_EXPRESSION);
|
|
384
|
+
return `name: DataDisco QA
|
|
385
|
+
on:
|
|
386
|
+
pull_request:
|
|
387
|
+
types: [opened, synchronize, reopened, ready_for_review]
|
|
388
|
+
|
|
389
|
+
permissions:
|
|
390
|
+
contents: read
|
|
391
|
+
|
|
392
|
+
jobs:
|
|
393
|
+
report-preview:
|
|
394
|
+
runs-on: ubuntu-latest
|
|
395
|
+
steps:
|
|
396
|
+
# ...wait for or deploy your preview before this step...
|
|
397
|
+
- uses: ${ACTION_REF}
|
|
398
|
+
with:
|
|
399
|
+
command: report-preview
|
|
400
|
+
url: ${url}
|
|
401
|
+
api-token: ${SECRET_EXPRESSION}
|
|
402
|
+
`;
|
|
106
403
|
}
|
|
107
|
-
|
|
404
|
+
|
|
405
|
+
// src/commands/setup-deps.ts
|
|
406
|
+
import { hostname, userInfo } from "os";
|
|
407
|
+
|
|
408
|
+
// src/files.ts
|
|
409
|
+
import {
|
|
410
|
+
chmodSync,
|
|
411
|
+
existsSync,
|
|
412
|
+
mkdirSync,
|
|
413
|
+
readdirSync,
|
|
414
|
+
readFileSync,
|
|
415
|
+
writeFileSync
|
|
416
|
+
} from "fs";
|
|
417
|
+
import { dirname } from "path";
|
|
418
|
+
var nodeFileSystem = {
|
|
419
|
+
exists: (path) => existsSync(path),
|
|
420
|
+
readFile: (path) => tryRead(path),
|
|
421
|
+
listDir: (path) => tryList(path),
|
|
422
|
+
writeFile: (path, content, options) => {
|
|
423
|
+
mkdirSync(dirname(path), { recursive: true, mode: options?.dirMode });
|
|
424
|
+
writeFileSync(path, content, { encoding: "utf8", mode: options?.mode });
|
|
425
|
+
if (options?.mode !== void 0) chmodSync(path, options.mode);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
function tryRead(path) {
|
|
108
429
|
try {
|
|
109
|
-
return
|
|
430
|
+
return readFileSync(path, "utf8");
|
|
110
431
|
} catch {
|
|
111
432
|
return void 0;
|
|
112
433
|
}
|
|
113
434
|
}
|
|
114
|
-
function
|
|
115
|
-
|
|
435
|
+
function tryList(path) {
|
|
436
|
+
try {
|
|
437
|
+
return readdirSync(path);
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
116
441
|
}
|
|
117
|
-
|
|
118
|
-
|
|
442
|
+
|
|
443
|
+
// src/git.ts
|
|
444
|
+
import { execFileSync } from "child_process";
|
|
445
|
+
var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
446
|
+
function detectRepoFullName(run2 = defaultRunner) {
|
|
447
|
+
const remote = tryGit(run2, ["remote", "get-url", "origin"]);
|
|
448
|
+
return remote ? parseRepoFromRemote(remote) : void 0;
|
|
119
449
|
}
|
|
120
|
-
function
|
|
121
|
-
return
|
|
450
|
+
function detectHeadSha(run2 = defaultRunner) {
|
|
451
|
+
return tryGit(run2, ["rev-parse", "HEAD"]);
|
|
452
|
+
}
|
|
453
|
+
function parseRepoFromRemote(remote) {
|
|
454
|
+
const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
|
|
455
|
+
return match ? match[1] : void 0;
|
|
456
|
+
}
|
|
457
|
+
function tryGit(run2, args) {
|
|
458
|
+
try {
|
|
459
|
+
return run2(args) || void 0;
|
|
460
|
+
} catch {
|
|
461
|
+
return void 0;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function detectRepoRoot(run2 = defaultRunner) {
|
|
465
|
+
return tryGit(run2, ["rev-parse", "--show-toplevel"]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/prompt.ts
|
|
469
|
+
import { createInterface } from "readline";
|
|
470
|
+
var PromptCancelledError = class extends Error {
|
|
471
|
+
constructor() {
|
|
472
|
+
super("Cancelled.");
|
|
473
|
+
this.name = "PromptCancelledError";
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
var YES_ANSWER = /^y(es)?$/i;
|
|
477
|
+
var NO_ANSWER = /^n(o)?$/i;
|
|
478
|
+
function createLinePrompt(input, output) {
|
|
479
|
+
let readline;
|
|
480
|
+
let ended = false;
|
|
481
|
+
let cancelled = false;
|
|
482
|
+
const lines = [];
|
|
483
|
+
const waiting = [];
|
|
484
|
+
const tty = input;
|
|
485
|
+
const cancel = () => {
|
|
486
|
+
cancelled = true;
|
|
487
|
+
for (const next of waiting.splice(0)) next.reject(new PromptCancelledError());
|
|
488
|
+
};
|
|
489
|
+
const open = () => {
|
|
490
|
+
if (readline) return readline;
|
|
491
|
+
readline = createInterface({ input, output });
|
|
492
|
+
readline.on("line", (line) => {
|
|
493
|
+
const next = waiting.shift();
|
|
494
|
+
if (next) next.resolve(line);
|
|
495
|
+
else lines.push(line);
|
|
496
|
+
});
|
|
497
|
+
readline.on("SIGINT", () => {
|
|
498
|
+
output.write("\n");
|
|
499
|
+
cancel();
|
|
500
|
+
readline?.close();
|
|
501
|
+
});
|
|
502
|
+
readline.on("close", () => {
|
|
503
|
+
ended = true;
|
|
504
|
+
if (tty.isTTY) cancel();
|
|
505
|
+
else for (const next of waiting.splice(0)) next.resolve(void 0);
|
|
506
|
+
});
|
|
507
|
+
return readline;
|
|
508
|
+
};
|
|
509
|
+
const nextLine = () => {
|
|
510
|
+
if (lines.length) return Promise.resolve(lines.shift());
|
|
511
|
+
if (ended) return Promise.resolve(void 0);
|
|
512
|
+
return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
|
|
513
|
+
};
|
|
514
|
+
return async (question, defaultValue) => {
|
|
515
|
+
if (cancelled) throw new PromptCancelledError();
|
|
516
|
+
const reader = open();
|
|
517
|
+
const text = formatQuestion(question, defaultValue);
|
|
518
|
+
if (ended) {
|
|
519
|
+
output.write(`${text}
|
|
520
|
+
`);
|
|
521
|
+
} else {
|
|
522
|
+
reader.setPrompt(text);
|
|
523
|
+
if (reader.terminal) tty.setRawMode?.(true);
|
|
524
|
+
reader.prompt();
|
|
525
|
+
reader.resume();
|
|
526
|
+
}
|
|
527
|
+
const answer = (await nextLine() ?? "").trim();
|
|
528
|
+
if (!ended) {
|
|
529
|
+
reader.pause();
|
|
530
|
+
if (reader.terminal) tty.setRawMode?.(false);
|
|
531
|
+
}
|
|
532
|
+
return answer || defaultValue || "";
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
var stdinPrompt;
|
|
536
|
+
var terminalPrompt = (question, defaultValue) => {
|
|
537
|
+
stdinPrompt ??= createLinePrompt(process.stdin, process.stderr);
|
|
538
|
+
return stdinPrompt(question, defaultValue);
|
|
539
|
+
};
|
|
540
|
+
async function confirm(prompt, question, defaultYes) {
|
|
541
|
+
const answer = await prompt(`${question} (${defaultYes ? "Y/n" : "y/N"})`);
|
|
542
|
+
if (YES_ANSWER.test(answer)) return true;
|
|
543
|
+
if (NO_ANSWER.test(answer)) return false;
|
|
544
|
+
return defaultYes;
|
|
545
|
+
}
|
|
546
|
+
function formatQuestion(question, defaultValue) {
|
|
547
|
+
return defaultValue ? `${question} [${defaultValue}] ` : `${question} `;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/shell.ts
|
|
551
|
+
import { spawn, spawnSync } from "child_process";
|
|
552
|
+
import { platform } from "os";
|
|
553
|
+
var runCommand = (command, args, input) => {
|
|
554
|
+
const result = spawnSync(command, args, { encoding: "utf8", input, stdio: "pipe" });
|
|
555
|
+
return {
|
|
556
|
+
ok: result.status === 0 && !result.error,
|
|
557
|
+
stdout: result.stdout ?? "",
|
|
558
|
+
stderr: result.stderr ?? ""
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
var BROWSER_OPENERS = {
|
|
562
|
+
darwin: { command: "open", args: [] },
|
|
563
|
+
win32: { command: "cmd", args: ["/c", "start", ""] }
|
|
564
|
+
};
|
|
565
|
+
var DEFAULT_BROWSER_OPENER = { command: "xdg-open", args: [] };
|
|
566
|
+
function openBrowser(url) {
|
|
567
|
+
const opener = BROWSER_OPENERS[platform()] ?? DEFAULT_BROWSER_OPENER;
|
|
568
|
+
try {
|
|
569
|
+
const child = spawn(opener.command, [...opener.args, url], {
|
|
570
|
+
detached: true,
|
|
571
|
+
stdio: "ignore"
|
|
572
|
+
});
|
|
573
|
+
child.on("error", () => {
|
|
574
|
+
});
|
|
575
|
+
child.unref();
|
|
576
|
+
} catch {
|
|
577
|
+
}
|
|
578
|
+
return Promise.resolve();
|
|
122
579
|
}
|
|
123
580
|
|
|
124
581
|
// src/api.ts
|
|
125
582
|
var REPORT_PREVIEW_PATH = "/api/qa/report-preview";
|
|
126
583
|
var RUN_STATUS_PATH = "/api/qa/run-status";
|
|
584
|
+
var PAIR_PATH = "/api/qa/cli/pair";
|
|
585
|
+
var PAIR_POLL_PATH = "/api/qa/cli/pair/poll";
|
|
586
|
+
var CONNECTIONS_PATH = "/api/v1/qa/connections";
|
|
127
587
|
function createApiClient(config, fetchImpl = fetch) {
|
|
128
588
|
return {
|
|
129
589
|
reportPreview: (params) => reportPreview(config, params, fetchImpl),
|
|
130
|
-
getRunStatus: (params) => getRunStatus(config, params, fetchImpl)
|
|
590
|
+
getRunStatus: (params) => getRunStatus(config, params, fetchImpl),
|
|
591
|
+
pair: (deviceName) => pair(config, deviceName, fetchImpl),
|
|
592
|
+
pollPairing: (deviceCode) => pollPairing(config, deviceCode, fetchImpl),
|
|
593
|
+
listConnections: (repo) => listConnections(config, repo, fetchImpl)
|
|
131
594
|
};
|
|
132
595
|
}
|
|
133
596
|
async function reportPreview(config, params, fetchImpl) {
|
|
134
597
|
const response = await fetchImpl(joinUrl(config.apiUrl, REPORT_PREVIEW_PATH), {
|
|
135
598
|
method: "POST",
|
|
136
|
-
headers: {
|
|
137
|
-
authorization: `Bearer ${config.apiToken}`,
|
|
138
|
-
"content-type": "application/json"
|
|
139
|
-
},
|
|
599
|
+
headers: { ...authHeaders(config), "content-type": "application/json" },
|
|
140
600
|
body: JSON.stringify({
|
|
141
601
|
repo: params.repo,
|
|
142
602
|
prNumber: params.prNumber,
|
|
@@ -149,25 +609,72 @@ async function reportPreview(config, params, fetchImpl) {
|
|
|
149
609
|
return { ok: true, runId: payload.runId };
|
|
150
610
|
}
|
|
151
611
|
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
error: errorMessage(payload),
|
|
155
|
-
retryable: booleanOrUndefined(payload.retryable),
|
|
156
|
-
code: stringOrUndefined2(payload.code)
|
|
612
|
+
...failure(response, payload),
|
|
613
|
+
retryable: booleanOrUndefined(payload.retryable)
|
|
157
614
|
};
|
|
158
615
|
}
|
|
159
616
|
async function getRunStatus(config, params, fetchImpl) {
|
|
160
617
|
const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
|
|
161
618
|
url.searchParams.set("repo", params.repo);
|
|
162
|
-
url
|
|
163
|
-
const response = await fetchImpl(url.toString(), {
|
|
164
|
-
headers: { authorization: `Bearer ${config.apiToken}` }
|
|
165
|
-
});
|
|
619
|
+
setRunLookupParam(url, params);
|
|
620
|
+
const response = await fetchImpl(url.toString(), { headers: authHeaders(config) });
|
|
166
621
|
const payload = await readJson(response);
|
|
167
622
|
if (response.ok && isRunStatus(payload.run)) {
|
|
168
623
|
return { ok: true, run: payload.run };
|
|
169
624
|
}
|
|
170
|
-
return
|
|
625
|
+
return failure(response, payload);
|
|
626
|
+
}
|
|
627
|
+
async function pair(config, deviceName, fetchImpl) {
|
|
628
|
+
const response = await fetchImpl(joinUrl(config.apiUrl, PAIR_PATH), {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "content-type": "application/json" },
|
|
631
|
+
body: JSON.stringify({ deviceName })
|
|
632
|
+
});
|
|
633
|
+
const payload = await readJson(response);
|
|
634
|
+
const pairing = response.ok ? parsePairingStart(payload.data) : void 0;
|
|
635
|
+
if (pairing) {
|
|
636
|
+
return { ok: true, pairing };
|
|
637
|
+
}
|
|
638
|
+
return failure(response, payload);
|
|
639
|
+
}
|
|
640
|
+
async function pollPairing(config, deviceCode, fetchImpl) {
|
|
641
|
+
const response = await fetchImpl(joinUrl(config.apiUrl, PAIR_POLL_PATH), {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers: { "content-type": "application/json" },
|
|
644
|
+
body: JSON.stringify({ deviceCode })
|
|
645
|
+
});
|
|
646
|
+
const payload = await readJson(response);
|
|
647
|
+
const data = response.ok && isRecord2(payload.data) ? payload.data : void 0;
|
|
648
|
+
if (data?.status === "PENDING") {
|
|
649
|
+
return { ok: true, pending: true };
|
|
650
|
+
}
|
|
651
|
+
const approval = data?.status === "APPROVED" ? parsePairingApproval(data) : void 0;
|
|
652
|
+
if (approval) {
|
|
653
|
+
return { ok: true, pending: false, approval };
|
|
654
|
+
}
|
|
655
|
+
return failure(response, payload);
|
|
656
|
+
}
|
|
657
|
+
async function listConnections(config, repo, fetchImpl) {
|
|
658
|
+
const url = new URL(joinUrl(config.apiUrl, CONNECTIONS_PATH));
|
|
659
|
+
url.searchParams.set("repo", repo);
|
|
660
|
+
const response = await fetchImpl(url.toString(), { headers: authHeaders(config) });
|
|
661
|
+
const payload = await readJson(response);
|
|
662
|
+
const data = response.ok && isRecord2(payload.data) ? payload.data : void 0;
|
|
663
|
+
const workspace = parseWorkspace(data?.workspace);
|
|
664
|
+
if (workspace && Array.isArray(data?.connections)) {
|
|
665
|
+
return { ok: true, workspace, connections: parseConnections(data.connections) };
|
|
666
|
+
}
|
|
667
|
+
return failure(response, payload);
|
|
668
|
+
}
|
|
669
|
+
function setRunLookupParam(url, params) {
|
|
670
|
+
if (params.prNumber !== void 0) {
|
|
671
|
+
url.searchParams.set("prNumber", String(params.prNumber));
|
|
672
|
+
} else if (params.headSha) {
|
|
673
|
+
url.searchParams.set("headSha", params.headSha);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function authHeaders(config) {
|
|
677
|
+
return config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {};
|
|
171
678
|
}
|
|
172
679
|
function joinUrl(base, path) {
|
|
173
680
|
return `${base.replace(/\/+$/, "")}${path}`;
|
|
@@ -180,6 +687,14 @@ async function readJson(response) {
|
|
|
180
687
|
return {};
|
|
181
688
|
}
|
|
182
689
|
}
|
|
690
|
+
function failure(response, payload) {
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
status: response.status,
|
|
694
|
+
error: errorMessage(payload),
|
|
695
|
+
code: stringOrUndefined(payload.code)
|
|
696
|
+
};
|
|
697
|
+
}
|
|
183
698
|
function errorMessage(payload) {
|
|
184
699
|
return isString(payload.error) ? payload.error : "Request failed.";
|
|
185
700
|
}
|
|
@@ -189,15 +704,103 @@ function isRecord2(value) {
|
|
|
189
704
|
function isString(value) {
|
|
190
705
|
return typeof value === "string";
|
|
191
706
|
}
|
|
192
|
-
function
|
|
707
|
+
function stringOrUndefined(value) {
|
|
193
708
|
return isString(value) ? value : void 0;
|
|
194
709
|
}
|
|
710
|
+
function stringOrNull(value) {
|
|
711
|
+
return isString(value) ? value : null;
|
|
712
|
+
}
|
|
713
|
+
function numberOrNull(value) {
|
|
714
|
+
return typeof value === "number" ? value : null;
|
|
715
|
+
}
|
|
195
716
|
function booleanOrUndefined(value) {
|
|
196
717
|
return typeof value === "boolean" ? value : void 0;
|
|
197
718
|
}
|
|
719
|
+
function booleanOr(value, fallback) {
|
|
720
|
+
return typeof value === "boolean" ? value : fallback;
|
|
721
|
+
}
|
|
198
722
|
function isRunStatus(value) {
|
|
199
723
|
return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
|
|
200
724
|
}
|
|
725
|
+
function parsePairingStart(value) {
|
|
726
|
+
if (!isRecord2(value) || !isString(value.deviceCode) || !isString(value.userCode) || !isString(value.verificationUrl) || typeof value.expiresInSeconds !== "number" || typeof value.intervalSeconds !== "number") {
|
|
727
|
+
return void 0;
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
deviceCode: value.deviceCode,
|
|
731
|
+
userCode: value.userCode,
|
|
732
|
+
verificationUrl: value.verificationUrl,
|
|
733
|
+
expiresInSeconds: value.expiresInSeconds,
|
|
734
|
+
intervalSeconds: value.intervalSeconds
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function parsePairingApproval(value) {
|
|
738
|
+
if (!isString(value.apiToken)) return void 0;
|
|
739
|
+
return {
|
|
740
|
+
apiToken: value.apiToken,
|
|
741
|
+
workspaceName: stringOrNull(value.workspaceName) ?? "",
|
|
742
|
+
workspaceSlug: stringOrNull(value.workspaceSlug) ?? ""
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function parseWorkspace(value) {
|
|
746
|
+
if (!isRecord2(value) || !isString(value.id)) return void 0;
|
|
747
|
+
return {
|
|
748
|
+
id: value.id,
|
|
749
|
+
name: stringOrNull(value.name) ?? "",
|
|
750
|
+
slug: stringOrNull(value.slug) ?? ""
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function parseConnections(values) {
|
|
754
|
+
const connections = [];
|
|
755
|
+
for (const value of values) {
|
|
756
|
+
const connection = parseConnection(value);
|
|
757
|
+
if (connection) connections.push(connection);
|
|
758
|
+
}
|
|
759
|
+
return connections;
|
|
760
|
+
}
|
|
761
|
+
function parseConnection(value) {
|
|
762
|
+
if (!isRecord2(value) || !isString(value.id) || !isString(value.url)) return void 0;
|
|
763
|
+
const kind = parseConnectionKind(value.kind);
|
|
764
|
+
const previewUrlSource = parsePreviewUrlSource(value.previewUrlSource);
|
|
765
|
+
if (!kind || !previewUrlSource) return void 0;
|
|
766
|
+
return {
|
|
767
|
+
id: value.id,
|
|
768
|
+
slug: stringOrNull(value.slug) ?? "",
|
|
769
|
+
kind,
|
|
770
|
+
status: stringOrNull(value.status) ?? "",
|
|
771
|
+
enabled: booleanOr(value.enabled, false),
|
|
772
|
+
repoFullName: stringOrNull(value.repoFullName),
|
|
773
|
+
hasInstallation: booleanOr(value.hasInstallation, false),
|
|
774
|
+
previewUrlSource,
|
|
775
|
+
previewUrlPattern: stringOrNull(value.previewUrlPattern),
|
|
776
|
+
deploymentEnvironment: stringOrNull(value.deploymentEnvironment),
|
|
777
|
+
waitForChecks: booleanOr(value.waitForChecks, false),
|
|
778
|
+
liveUrl: stringOrNull(value.liveUrl),
|
|
779
|
+
findingPrefix: stringOrNull(value.findingPrefix),
|
|
780
|
+
url: value.url,
|
|
781
|
+
lastRun: parseLastRun(value.lastRun)
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function parseConnectionKind(value) {
|
|
785
|
+
if (value === "GITHUB" || value === "URL_ONLY") return value;
|
|
786
|
+
return void 0;
|
|
787
|
+
}
|
|
788
|
+
function parsePreviewUrlSource(value) {
|
|
789
|
+
if (value === "DEPLOYMENT_STATUS" || value === "URL_PATTERN") return value;
|
|
790
|
+
return void 0;
|
|
791
|
+
}
|
|
792
|
+
function parseLastRun(value) {
|
|
793
|
+
if (!isRecord2(value) || !isString(value.id)) return null;
|
|
794
|
+
return {
|
|
795
|
+
id: value.id,
|
|
796
|
+
status: stringOrNull(value.status) ?? "",
|
|
797
|
+
verdict: stringOrNull(value.verdict),
|
|
798
|
+
previewUrl: stringOrNull(value.previewUrl),
|
|
799
|
+
createdAt: stringOrNull(value.createdAt) ?? "",
|
|
800
|
+
prNumber: numberOrNull(value.prNumber),
|
|
801
|
+
branch: stringOrNull(value.branch)
|
|
802
|
+
};
|
|
803
|
+
}
|
|
201
804
|
|
|
202
805
|
// src/logger.ts
|
|
203
806
|
import pc from "picocolors";
|
|
@@ -224,6 +827,605 @@ var defaultDeps = {
|
|
|
224
827
|
now: () => Date.now()
|
|
225
828
|
};
|
|
226
829
|
|
|
830
|
+
// src/commands/setup-deps.ts
|
|
831
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
832
|
+
var METHOD_NOT_ALLOWED = 405;
|
|
833
|
+
var defaultSetupDeps = {
|
|
834
|
+
...defaultDeps,
|
|
835
|
+
...nodeFileSystem,
|
|
836
|
+
prompt: terminalPrompt,
|
|
837
|
+
openBrowser,
|
|
838
|
+
runCommand,
|
|
839
|
+
detectRepo: () => detectRepoFullName(),
|
|
840
|
+
repoRoot: () => detectRepoRoot() ?? process.cwd(),
|
|
841
|
+
deviceName: () => `${hostname()} (${currentUsername()})`,
|
|
842
|
+
configFilePath: resolveConfigFilePath(),
|
|
843
|
+
probeUrl: (url) => probeUrl(url)
|
|
844
|
+
};
|
|
845
|
+
function currentUsername(lookup = userInfo) {
|
|
846
|
+
try {
|
|
847
|
+
return lookup().username;
|
|
848
|
+
} catch {
|
|
849
|
+
return process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function probeUrl(url) {
|
|
853
|
+
try {
|
|
854
|
+
const head = await fetch(url, {
|
|
855
|
+
method: "HEAD",
|
|
856
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
857
|
+
});
|
|
858
|
+
if (head.status !== METHOD_NOT_ALLOWED) return { ok: head.ok, status: head.status };
|
|
859
|
+
const get = await fetch(url, { method: "GET", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
860
|
+
return { ok: get.ok, status: get.status };
|
|
861
|
+
} catch (error) {
|
|
862
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/commands/doctor.ts
|
|
867
|
+
var MARKS = { pass: "\u2713", fail: "\u2717", skip: "\u2013" };
|
|
868
|
+
var REPO_HINT = "pass --repo owner/name or run inside a checkout with a github.com origin remote";
|
|
869
|
+
var INIT_HINT = "run `npx @datadisco/qa init`";
|
|
870
|
+
async function runDoctor(options, deps = defaultSetupDeps) {
|
|
871
|
+
const checks = await collectChecks(options, deps);
|
|
872
|
+
for (const check of checks) printCheck(check, deps);
|
|
873
|
+
return checks.some((check) => check.state === "fail") ? 1 : 0;
|
|
874
|
+
}
|
|
875
|
+
async function collectChecks(options, deps) {
|
|
876
|
+
const apiUrl = resolveApiUrl(options);
|
|
877
|
+
const apiToken = findExistingToken(options, apiUrl, deps);
|
|
878
|
+
if (!apiToken) {
|
|
879
|
+
return [fail("API token", "run `datadisco-qa login`")];
|
|
880
|
+
}
|
|
881
|
+
const repo = options.repo ?? deps.detectRepo();
|
|
882
|
+
if (!repo) {
|
|
883
|
+
return [pass("API token present"), fail("Repository", REPO_HINT)];
|
|
884
|
+
}
|
|
885
|
+
return checksForRepo(deps.createApi({ apiUrl, apiToken }), apiUrl, repo, deps);
|
|
886
|
+
}
|
|
887
|
+
async function checksForRepo(api, apiUrl, repo, deps) {
|
|
888
|
+
const outcome = await api.listConnections(repo);
|
|
889
|
+
if (!outcome.ok) {
|
|
890
|
+
return [tokenFailure(outcome.status, outcome.error)];
|
|
891
|
+
}
|
|
892
|
+
const connection = findGithubConnection(outcome);
|
|
893
|
+
if (!connection) {
|
|
894
|
+
return [
|
|
895
|
+
pass(`API token valid for workspace ${outcome.workspace.name}`),
|
|
896
|
+
fail(`Repository ${repo} is not connected`, `connect it at ${connectRepoUrl(apiUrl, repo)}`)
|
|
897
|
+
];
|
|
898
|
+
}
|
|
899
|
+
const detection = detectDeployHost(deps.repoRoot(), deps);
|
|
900
|
+
const workflow = readWorkflowState(deps);
|
|
901
|
+
const notNeeded = reasonCiReportNotNeeded(connection, detection, workflow);
|
|
902
|
+
return [
|
|
903
|
+
pass(`API token valid for workspace ${outcome.workspace.name}`),
|
|
904
|
+
connectionCheck(repo, connection),
|
|
905
|
+
previewSourceCheck(connection, detection, workflow),
|
|
906
|
+
notNeeded ? skip(`Workflow not needed: ${notNeeded}`) : workflowCheck(workflow),
|
|
907
|
+
notNeeded ? skip(`${API_TOKEN_SECRET_NAME} secret not needed: ${notNeeded}`) : secretCheck(repo, deps),
|
|
908
|
+
await liveUrlCheck(connection, deps),
|
|
909
|
+
lastRunCheck(connection.lastRun)
|
|
910
|
+
];
|
|
911
|
+
}
|
|
912
|
+
function readWorkflowState(deps) {
|
|
913
|
+
const path = workflowFilePath(deps.repoRoot());
|
|
914
|
+
if (!deps.exists(path)) return "missing";
|
|
915
|
+
return triggersOnDeploymentStatus(deps.readFile(path) ?? "") ? "deployment-status" : "reports-previews";
|
|
916
|
+
}
|
|
917
|
+
function tokenFailure(status, error) {
|
|
918
|
+
if (status === 401) return fail("API token rejected", "run `datadisco-qa login`");
|
|
919
|
+
return fail(`Could not list connections (${error})`, "check --api-url and your network");
|
|
920
|
+
}
|
|
921
|
+
function connectionCheck(repo, connection) {
|
|
922
|
+
const problem = describeConnectionProblem(connection);
|
|
923
|
+
return problem ? fail(`Repository ${repo} is connected but unhealthy`, `${problem}: ${connection.url}`) : pass(`Repository ${repo} is connected (${connection.url})`);
|
|
924
|
+
}
|
|
925
|
+
function previewSourceCheck(connection, detection, workflow) {
|
|
926
|
+
if (connection.previewUrlSource === "URL_PATTERN") {
|
|
927
|
+
return pass(`Preview source: URL pattern ${connection.previewUrlPattern ?? "(unset)"}`);
|
|
928
|
+
}
|
|
929
|
+
const lastPreview = connection.lastRun?.previewUrl;
|
|
930
|
+
if (lastPreview) {
|
|
931
|
+
return pass(`Preview source: deploy events (last preview ${lastPreview})`);
|
|
932
|
+
}
|
|
933
|
+
if (detection.deployEvents) {
|
|
934
|
+
return pass(`Preview source: deploy events from ${detection.host} (no preview received yet)`);
|
|
935
|
+
}
|
|
936
|
+
if (workflow === "reports-previews") {
|
|
937
|
+
return pass(`Preview source: reported by ${WORKFLOW_RELATIVE_PATH} (no preview received yet)`);
|
|
938
|
+
}
|
|
939
|
+
if (workflow === "deployment-status") {
|
|
940
|
+
return fail(
|
|
941
|
+
`Preview source: ${WORKFLOW_RELATIVE_PATH} runs on deployment_status, and no preview URL has ever arrived`,
|
|
942
|
+
`${describeMissingDeployEvents(detection)}, so it never fires \u2014 report a URL pattern instead; ${INIT_HINT}`
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
return fail(
|
|
946
|
+
"Preview source: deploy events, but no preview URL has ever arrived",
|
|
947
|
+
`${describeMissingDeployEvents(detection)} \u2014 add a CI report or a URL pattern; ${INIT_HINT}`
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
function describeMissingDeployEvents(detection) {
|
|
951
|
+
if (detection.host === "unknown") return "the deploy host was not detected";
|
|
952
|
+
return isDeployEventHost(detection.host) ? `${detection.host} is deployed from CI, which emits no deploy events` : `${detection.host} does not emit deploy events`;
|
|
953
|
+
}
|
|
954
|
+
function reasonCiReportNotNeeded(connection, detection, workflow) {
|
|
955
|
+
if (workflow !== "missing") return void 0;
|
|
956
|
+
if (connection.previewUrlSource === "URL_PATTERN") return "previews come from the URL pattern";
|
|
957
|
+
if (detection.deployEvents) return `${detection.host} emits deploy events`;
|
|
958
|
+
if (connection.lastRun?.previewUrl) return "previews already arrive as deploy events";
|
|
959
|
+
return void 0;
|
|
960
|
+
}
|
|
961
|
+
function workflowCheck(workflow) {
|
|
962
|
+
return workflow !== "missing" ? pass(`${WORKFLOW_RELATIVE_PATH} present`) : fail(`${WORKFLOW_RELATIVE_PATH} missing`, INIT_HINT);
|
|
963
|
+
}
|
|
964
|
+
function secretCheck(repo, deps) {
|
|
965
|
+
const gh = checkGh(deps.runCommand);
|
|
966
|
+
if (gh !== "ready") {
|
|
967
|
+
return skip(
|
|
968
|
+
`${API_TOKEN_SECRET_NAME} secret not checked (gh ${gh === "missing" ? "not installed" : "not signed in"})`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
const names = listRepoSecretNames(deps.runCommand, repo);
|
|
972
|
+
if (!names)
|
|
973
|
+
return skip(`${API_TOKEN_SECRET_NAME} secret not checked (gh could not list secrets)`);
|
|
974
|
+
return names.includes(API_TOKEN_SECRET_NAME) ? pass(`${API_TOKEN_SECRET_NAME} secret set on ${repo}`) : fail(
|
|
975
|
+
`${API_TOKEN_SECRET_NAME} secret missing on ${repo}`,
|
|
976
|
+
`gh secret set ${API_TOKEN_SECRET_NAME} --repo ${repo}`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
async function liveUrlCheck(connection, deps) {
|
|
980
|
+
if (!connection.liveUrl) return skip("Live URL not set");
|
|
981
|
+
const probe = await deps.probeUrl(connection.liveUrl);
|
|
982
|
+
if (probe.ok) return pass(`Live URL reachable (${connection.liveUrl})`);
|
|
983
|
+
const detail = probe.status !== void 0 ? `HTTP ${probe.status}` : probe.error ?? "unreachable";
|
|
984
|
+
return fail(
|
|
985
|
+
`Live URL unreachable (${connection.liveUrl}: ${detail})`,
|
|
986
|
+
"check the URL on the connection page"
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
function lastRunCheck(lastRun) {
|
|
990
|
+
if (!lastRun) return skip("No QA runs yet");
|
|
991
|
+
const target = lastRun.prNumber !== null ? `PR #${lastRun.prNumber}` : lastRun.branch ?? "unknown ref";
|
|
992
|
+
const verdict = lastRun.verdict ? `, verdict ${lastRun.verdict}` : "";
|
|
993
|
+
return pass(`Last run: ${lastRun.status}${verdict} on ${target} at ${lastRun.createdAt}`);
|
|
994
|
+
}
|
|
995
|
+
function printCheck(check, deps) {
|
|
996
|
+
const line = `${MARKS[check.state]} ${check.label}`;
|
|
997
|
+
if (check.state === "pass") return deps.logger.success(line);
|
|
998
|
+
if (check.state === "skip") return deps.logger.info(line);
|
|
999
|
+
deps.logger.error(check.hint ? `${line} \u2014 ${check.hint}` : line);
|
|
1000
|
+
}
|
|
1001
|
+
function pass(label) {
|
|
1002
|
+
return { state: "pass", label };
|
|
1003
|
+
}
|
|
1004
|
+
function fail(label, hint) {
|
|
1005
|
+
return { state: "fail", label, hint };
|
|
1006
|
+
}
|
|
1007
|
+
function skip(label) {
|
|
1008
|
+
return { state: "skip", label };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/commands/init.ts
|
|
1012
|
+
var INIT_EXIT_CODE = {
|
|
1013
|
+
DONE: 0,
|
|
1014
|
+
ERROR: 1,
|
|
1015
|
+
STOPPED_WAITING: 2
|
|
1016
|
+
};
|
|
1017
|
+
var CONNECTION_POLL_INTERVAL_MS = 5e3;
|
|
1018
|
+
var CONNECTION_WAIT_TIMEOUT_MS = 10 * 6e4;
|
|
1019
|
+
var EXAMPLE_PATTERN = `https://pr-${PATTERN_PLACEHOLDER}.preview.example.com`;
|
|
1020
|
+
var REPO_MISSING_MESSAGE = "Repository not set: pass --repo owner/name, or run inside a git checkout with a github.com origin remote.";
|
|
1021
|
+
async function runInit(options, deps = defaultSetupDeps) {
|
|
1022
|
+
const session = await resolveSession(options, deps);
|
|
1023
|
+
if (!session.ok) {
|
|
1024
|
+
deps.logger.error(session.error);
|
|
1025
|
+
return INIT_EXIT_CODE.ERROR;
|
|
1026
|
+
}
|
|
1027
|
+
const { apiUrl, apiToken, api } = session.session;
|
|
1028
|
+
const repo = resolveRepo(options, deps);
|
|
1029
|
+
deps.logger.info(`Repository: ${repo}`);
|
|
1030
|
+
const connection = await ensureConnection(api, apiUrl, repo, options, deps);
|
|
1031
|
+
if (typeof connection === "number") return connection;
|
|
1032
|
+
const detection = detectDeployHost(deps.repoRoot(), deps);
|
|
1033
|
+
deps.logger.info(describeDetection(detection));
|
|
1034
|
+
const written = await configurePreviewReporting(connection, detection, options, deps);
|
|
1035
|
+
if (written === "needs-pattern") return INIT_EXIT_CODE.ERROR;
|
|
1036
|
+
if (written === "workflow-present") {
|
|
1037
|
+
await ensureSecret(repo, apiToken, options, deps);
|
|
1038
|
+
}
|
|
1039
|
+
printSummary(connection, written, deps);
|
|
1040
|
+
return INIT_EXIT_CODE.DONE;
|
|
1041
|
+
}
|
|
1042
|
+
function resolveRepo(options, deps) {
|
|
1043
|
+
const repo = options.repo ?? deps.detectRepo();
|
|
1044
|
+
if (!repo) throw new ConfigError(REPO_MISSING_MESSAGE);
|
|
1045
|
+
return repo;
|
|
1046
|
+
}
|
|
1047
|
+
async function ensureConnection(api, apiUrl, repo, options, deps) {
|
|
1048
|
+
const outcome = await api.listConnections(repo);
|
|
1049
|
+
if (!outcome.ok) {
|
|
1050
|
+
deps.logger.error(describeListFailure(outcome.status, outcome.error));
|
|
1051
|
+
return INIT_EXIT_CODE.ERROR;
|
|
1052
|
+
}
|
|
1053
|
+
const existing = findGithubConnection(outcome);
|
|
1054
|
+
if (existing) {
|
|
1055
|
+
deps.logger.success(`Connected to workspace ${outcome.workspace.name} (${existing.url})`);
|
|
1056
|
+
return existing;
|
|
1057
|
+
}
|
|
1058
|
+
const connectUrl = connectRepoUrl(apiUrl, repo);
|
|
1059
|
+
deps.logger.warn("This repo is not connected yet.");
|
|
1060
|
+
deps.logger.info(`Connect it at ${connectUrl}`);
|
|
1061
|
+
if (options.browser !== false) await deps.openBrowser(connectUrl);
|
|
1062
|
+
if (options.yes) {
|
|
1063
|
+
deps.logger.info("Re-run `datadisco-qa init` once the repo is connected.");
|
|
1064
|
+
return INIT_EXIT_CODE.STOPPED_WAITING;
|
|
1065
|
+
}
|
|
1066
|
+
return waitForConnection(api, repo, deps);
|
|
1067
|
+
}
|
|
1068
|
+
async function waitForConnection(api, repo, deps) {
|
|
1069
|
+
const deadline = deps.now() + CONNECTION_WAIT_TIMEOUT_MS;
|
|
1070
|
+
deps.logger.info("Waiting for you to connect the repo in the browser\u2026");
|
|
1071
|
+
while (deps.now() < deadline) {
|
|
1072
|
+
await deps.sleep(CONNECTION_POLL_INTERVAL_MS);
|
|
1073
|
+
const outcome = await pollConnections(api, repo);
|
|
1074
|
+
if (!outcome.ok && isFinalListFailure(outcome.status)) {
|
|
1075
|
+
deps.logger.error(describeListFailure(outcome.status, outcome.error));
|
|
1076
|
+
return INIT_EXIT_CODE.ERROR;
|
|
1077
|
+
}
|
|
1078
|
+
const connection = findGithubConnection(outcome);
|
|
1079
|
+
if (connection) {
|
|
1080
|
+
deps.logger.success(`Connected: ${connection.url}`);
|
|
1081
|
+
return connection;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
deps.logger.error(
|
|
1085
|
+
"Gave up waiting for the repo to be connected. Re-run `datadisco-qa init` once it is."
|
|
1086
|
+
);
|
|
1087
|
+
return INIT_EXIT_CODE.STOPPED_WAITING;
|
|
1088
|
+
}
|
|
1089
|
+
async function pollConnections(api, repo) {
|
|
1090
|
+
try {
|
|
1091
|
+
return await api.listConnections(repo);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function isFinalListFailure(status) {
|
|
1097
|
+
return status >= 400 && status < 500 && status !== 429;
|
|
1098
|
+
}
|
|
1099
|
+
function describeListFailure(status, error) {
|
|
1100
|
+
if (status === 401) {
|
|
1101
|
+
return `The API token was rejected (${error}). Run \`datadisco-qa login\` to sign in again.`;
|
|
1102
|
+
}
|
|
1103
|
+
return `Could not look up the repo's connections: ${error}`;
|
|
1104
|
+
}
|
|
1105
|
+
function describeDetection(detection) {
|
|
1106
|
+
const evidence = detection.evidence.length > 0 ? ` (${detection.evidence.join(", ")})` : "";
|
|
1107
|
+
return detection.host === "unknown" ? `Deploy host: not detected${evidence}` : `Deploy host: ${detection.host}${evidence}`;
|
|
1108
|
+
}
|
|
1109
|
+
async function configurePreviewReporting(connection, detection, options, deps) {
|
|
1110
|
+
if (connection.previewUrlSource === "URL_PATTERN") {
|
|
1111
|
+
deps.logger.info(
|
|
1112
|
+
`Nothing to add: the connection derives previews from the URL pattern ${connection.previewUrlPattern ?? ""}`.trimEnd()
|
|
1113
|
+
);
|
|
1114
|
+
return "nothing-to-add";
|
|
1115
|
+
}
|
|
1116
|
+
if (detection.deployEvents) {
|
|
1117
|
+
deps.logger.info("Nothing to add: DataDisco picks previews up from deploy events.");
|
|
1118
|
+
return "nothing-to-add";
|
|
1119
|
+
}
|
|
1120
|
+
return offerWorkflow(connection, options, deps);
|
|
1121
|
+
}
|
|
1122
|
+
async function offerWorkflow(connection, options, deps) {
|
|
1123
|
+
deps.logger.info(
|
|
1124
|
+
"Previews from this host don't reach GitHub as deploy events, so CI needs to report each preview URL."
|
|
1125
|
+
);
|
|
1126
|
+
const path = workflowFilePath(deps.repoRoot());
|
|
1127
|
+
if (deps.exists(path) && !await confirmOverwrite(options, deps)) {
|
|
1128
|
+
deps.logger.info(`Keeping the existing ${WORKFLOW_RELATIVE_PATH}.`);
|
|
1129
|
+
return "workflow-present";
|
|
1130
|
+
}
|
|
1131
|
+
const pattern = await choosePattern(connection, options, deps);
|
|
1132
|
+
if (!pattern) {
|
|
1133
|
+
printPatternNeeded(connection, deps);
|
|
1134
|
+
return "needs-pattern";
|
|
1135
|
+
}
|
|
1136
|
+
deps.writeFile(path, renderWorkflow(pattern));
|
|
1137
|
+
deps.logger.success(`Wrote ${WORKFLOW_RELATIVE_PATH}: ${describeRecipe(pattern)}`);
|
|
1138
|
+
return "workflow-present";
|
|
1139
|
+
}
|
|
1140
|
+
async function confirmOverwrite(options, deps) {
|
|
1141
|
+
if (options.yes) return false;
|
|
1142
|
+
return confirm(deps.prompt, `${WORKFLOW_RELATIVE_PATH} already exists. Overwrite it?`, false);
|
|
1143
|
+
}
|
|
1144
|
+
async function choosePattern(connection, options, deps) {
|
|
1145
|
+
const suggested = connection.previewUrlPattern ?? void 0;
|
|
1146
|
+
if (options.yes) {
|
|
1147
|
+
if (suggested) assertValidPreviewUrlPattern(suggested);
|
|
1148
|
+
return suggested;
|
|
1149
|
+
}
|
|
1150
|
+
return askPattern(suggested, deps);
|
|
1151
|
+
}
|
|
1152
|
+
async function askPattern(suggested, deps) {
|
|
1153
|
+
let defaultValue = suggested;
|
|
1154
|
+
for (; ; ) {
|
|
1155
|
+
const pattern = await deps.prompt(
|
|
1156
|
+
`Preview URL pattern with ${PATTERN_PLACEHOLDER}, e.g. ${EXAMPLE_PATTERN}`,
|
|
1157
|
+
defaultValue
|
|
1158
|
+
);
|
|
1159
|
+
if (!pattern) return void 0;
|
|
1160
|
+
try {
|
|
1161
|
+
assertValidPreviewUrlPattern(pattern);
|
|
1162
|
+
return pattern;
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
if (!(error instanceof ConfigError)) throw error;
|
|
1165
|
+
deps.logger.error(error.message);
|
|
1166
|
+
defaultValue = void 0;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
function printPatternNeeded(connection, deps) {
|
|
1171
|
+
deps.logger.error(
|
|
1172
|
+
`A preview URL pattern is needed to report previews from CI, e.g. ${EXAMPLE_PATTERN}. Nothing was written.`
|
|
1173
|
+
);
|
|
1174
|
+
deps.logger.info(
|
|
1175
|
+
`Set one on ${connection.url} or re-run \`datadisco-qa init\` and enter it when asked.`
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
async function ensureSecret(repo, apiToken, options, deps) {
|
|
1179
|
+
const gh = checkGh(deps.runCommand);
|
|
1180
|
+
if (gh !== "ready") {
|
|
1181
|
+
printManualSecretSteps(repo, gh, deps);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const existing = listRepoSecretNames(deps.runCommand, repo);
|
|
1185
|
+
if (existing?.includes(API_TOKEN_SECRET_NAME)) {
|
|
1186
|
+
await replaceExistingSecret(repo, apiToken, options, deps);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const wanted = await confirmStoreSecret(repo, existing !== void 0, options, deps);
|
|
1190
|
+
if (!wanted) {
|
|
1191
|
+
printManualSecretSteps(repo, gh, deps);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
storeSecret(repo, apiToken, gh, deps);
|
|
1195
|
+
}
|
|
1196
|
+
async function replaceExistingSecret(repo, apiToken, options, deps) {
|
|
1197
|
+
const replace = !options.yes && await confirm(
|
|
1198
|
+
deps.prompt,
|
|
1199
|
+
`${API_TOKEN_SECRET_NAME} is already set on ${repo}. Replace it with your personal sign-in token?`,
|
|
1200
|
+
false
|
|
1201
|
+
);
|
|
1202
|
+
if (!replace) {
|
|
1203
|
+
deps.logger.info(`Keeping the existing ${API_TOKEN_SECRET_NAME} secret on ${repo}.`);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
storeSecret(repo, apiToken, "ready", deps);
|
|
1207
|
+
}
|
|
1208
|
+
async function confirmStoreSecret(repo, listed, options, deps) {
|
|
1209
|
+
const question = `Store your personal sign-in token as the ${API_TOKEN_SECRET_NAME} Actions secret on ${repo} via gh?`;
|
|
1210
|
+
if (listed) return options.yes || confirm(deps.prompt, question, true);
|
|
1211
|
+
deps.logger.warn(`Could not check whether ${API_TOKEN_SECRET_NAME} is already set on ${repo}.`);
|
|
1212
|
+
return !options.yes && confirm(deps.prompt, `${question} It replaces the secret if set.`, false);
|
|
1213
|
+
}
|
|
1214
|
+
function storeSecret(repo, apiToken, gh, deps) {
|
|
1215
|
+
const result = setRepoSecret(deps.runCommand, repo, API_TOKEN_SECRET_NAME, apiToken);
|
|
1216
|
+
if (result.ok) {
|
|
1217
|
+
deps.logger.success(`Set the ${API_TOKEN_SECRET_NAME} secret on ${repo}.`);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
deps.logger.warn(`gh could not set the secret: ${result.stderr.trim() || "unknown error"}`);
|
|
1221
|
+
printManualSecretSteps(repo, gh, deps);
|
|
1222
|
+
}
|
|
1223
|
+
var MANUAL_SECRET_REASONS = {
|
|
1224
|
+
missing: "gh is not installed",
|
|
1225
|
+
unauthenticated: "gh is not signed in (run `gh auth login`)",
|
|
1226
|
+
ready: "skipped"
|
|
1227
|
+
};
|
|
1228
|
+
function printManualSecretSteps(repo, gh, deps) {
|
|
1229
|
+
deps.logger.info(
|
|
1230
|
+
`Add the ${API_TOKEN_SECRET_NAME} secret manually (${MANUAL_SECRET_REASONS[gh]}):`
|
|
1231
|
+
);
|
|
1232
|
+
deps.logger.info(` 1. Open https://github.com/${repo}/settings/secrets/actions/new`);
|
|
1233
|
+
deps.logger.info(` 2. Name: ${API_TOKEN_SECRET_NAME}`);
|
|
1234
|
+
deps.logger.info(
|
|
1235
|
+
" 3. Value: the workspace API token from Workspace settings \u2192 Integrations \u2192 API tokens"
|
|
1236
|
+
);
|
|
1237
|
+
deps.logger.info(` (or: gh secret set ${API_TOKEN_SECRET_NAME} --repo ${repo})`);
|
|
1238
|
+
}
|
|
1239
|
+
function printSummary(connection, written, deps) {
|
|
1240
|
+
deps.logger.info("");
|
|
1241
|
+
deps.logger.success(`Done. QA for this repo: ${connection.url}`);
|
|
1242
|
+
deps.logger.info(
|
|
1243
|
+
written === "workflow-present" ? `Commit ${WORKFLOW_RELATIVE_PATH} and open a pull request to start the first run.` : "Nothing was written \u2014 open a pull request to start the first run."
|
|
1244
|
+
);
|
|
1245
|
+
deps.logger.info("Run `npx @datadisco/qa doctor` any time to re-check.");
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// src/commands/login.ts
|
|
1249
|
+
async function runLogin(options, deps = defaultSetupDeps) {
|
|
1250
|
+
const apiUrl = resolveApiUrl(options);
|
|
1251
|
+
const result = await signIn(apiUrl, deps.createApi({ apiUrl }), deps, {
|
|
1252
|
+
openBrowser: options.browser !== false
|
|
1253
|
+
});
|
|
1254
|
+
if (!result.ok) {
|
|
1255
|
+
deps.logger.error(result.error);
|
|
1256
|
+
return 1;
|
|
1257
|
+
}
|
|
1258
|
+
deps.logger.info(`Token saved to ${deps.configFilePath}`);
|
|
1259
|
+
return 0;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/commands/logout.ts
|
|
1263
|
+
async function runLogout(options, deps = defaultSetupDeps) {
|
|
1264
|
+
const apiUrl = resolveApiUrl(options);
|
|
1265
|
+
if (!deleteStoredToken(apiUrl, deps)) {
|
|
1266
|
+
deps.logger.info(`No saved sign-in for ${apiUrl}.`);
|
|
1267
|
+
return 0;
|
|
1268
|
+
}
|
|
1269
|
+
deps.logger.success(`Signed out of ${apiUrl} (removed from ${deps.configFilePath}).`);
|
|
1270
|
+
return 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// src/context.ts
|
|
1274
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1275
|
+
var REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;
|
|
1276
|
+
var PR_REF_PATTERN = /^refs\/pull\/(\d+)\//;
|
|
1277
|
+
var REPO_MISSING_MESSAGE2 = "Repository not set: pass --repo owner/name (auto-detected from the GitHub Actions env or a github.com origin remote on the git checkout).";
|
|
1278
|
+
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).";
|
|
1279
|
+
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).";
|
|
1280
|
+
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).";
|
|
1281
|
+
function resolveRunContext(input = {}) {
|
|
1282
|
+
const context = resolveRunContextAllowingShaOnly(input);
|
|
1283
|
+
if (context.prNumber === void 0) {
|
|
1284
|
+
throw new ConfigError(PR_NUMBER_MISSING_MESSAGE);
|
|
1285
|
+
}
|
|
1286
|
+
return { ...context, prNumber: context.prNumber };
|
|
1287
|
+
}
|
|
1288
|
+
function resolveRunContextAllowingShaOnly(input = {}) {
|
|
1289
|
+
const env = input.env ?? process.env;
|
|
1290
|
+
const payload = loadEventPayload(input, env);
|
|
1291
|
+
const headSha = resolveHeadSha(input, env, payload) ?? detectHeadSha(input.gitRunner);
|
|
1292
|
+
if (!headSha) {
|
|
1293
|
+
throw new ConfigError(HEAD_SHA_MISSING_MESSAGE);
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
repo: resolveRepo2(input, env),
|
|
1297
|
+
prNumber: resolvePrNumber(input, env, payload),
|
|
1298
|
+
headSha
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function resolvePrContext(input = {}) {
|
|
1302
|
+
const env = input.env ?? process.env;
|
|
1303
|
+
const payload = loadEventPayload(input, env);
|
|
1304
|
+
const prNumber = resolvePrNumber(input, env, payload);
|
|
1305
|
+
const knownHeadSha = resolveHeadSha(input, env, payload);
|
|
1306
|
+
const headSha = prNumber === void 0 ? knownHeadSha ?? detectHeadSha(input.gitRunner) : knownHeadSha;
|
|
1307
|
+
if (prNumber === void 0 && !headSha) {
|
|
1308
|
+
throw new ConfigError(PR_OR_SHA_MISSING_MESSAGE);
|
|
1309
|
+
}
|
|
1310
|
+
return { repo: resolveRepo2(input, env), prNumber, headSha };
|
|
1311
|
+
}
|
|
1312
|
+
function resolveRepo2(input, env = {}) {
|
|
1313
|
+
const repo = input.repo ?? env.GITHUB_REPOSITORY ?? detectRepoFullName(input.gitRunner);
|
|
1314
|
+
if (!repo) {
|
|
1315
|
+
throw new ConfigError(REPO_MISSING_MESSAGE2);
|
|
1316
|
+
}
|
|
1317
|
+
if (!REPO_PATTERN.test(repo)) {
|
|
1318
|
+
throw new ConfigError(`--repo must look like "owner/name", got "${repo}".`);
|
|
1319
|
+
}
|
|
1320
|
+
return repo;
|
|
1321
|
+
}
|
|
1322
|
+
function resolveHeadSha(input, env = {}, payload) {
|
|
1323
|
+
return input.sha ?? payload.headSha ?? env.GITHUB_SHA;
|
|
1324
|
+
}
|
|
1325
|
+
function resolvePrNumber(input, env = {}, payload) {
|
|
1326
|
+
const candidate = input.pr ?? parsePrFromRef(env.GITHUB_REF) ?? payload.prNumber ?? resolvePrNumberFromCiEnv(env);
|
|
1327
|
+
return toPositiveInteger(candidate);
|
|
1328
|
+
}
|
|
1329
|
+
function toPositiveInteger(candidate) {
|
|
1330
|
+
if (candidate === void 0) return void 0;
|
|
1331
|
+
const parsed = Number(candidate);
|
|
1332
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
1333
|
+
}
|
|
1334
|
+
function parsePrFromRef(ref) {
|
|
1335
|
+
const match = ref?.match(PR_REF_PATTERN);
|
|
1336
|
+
return match ? match[1] : void 0;
|
|
1337
|
+
}
|
|
1338
|
+
var CI_PR_ENV_SOURCES = [
|
|
1339
|
+
{
|
|
1340
|
+
// CircleCI: CIRCLE_PULL_REQUEST is a URL (https://github.com/o/r/pull/123);
|
|
1341
|
+
// CIRCLE_PR_NUMBER is a plain number and is checked as a fallback.
|
|
1342
|
+
ci: "CircleCI",
|
|
1343
|
+
read: (env) => numericPrCandidate(trailingNumber(env.CIRCLE_PULL_REQUEST)) ?? numericPrCandidate(env.CIRCLE_PR_NUMBER)
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
// Jenkins (Multibranch/PR builders): CHANGE_ID is the PR number.
|
|
1347
|
+
ci: "Jenkins",
|
|
1348
|
+
read: (env) => numericPrCandidate(env.CHANGE_ID)
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
// Buildkite: BUILDKITE_PULL_REQUEST is "false" on non-PR builds.
|
|
1352
|
+
ci: "Buildkite",
|
|
1353
|
+
read: (env) => numericPrCandidate(env.BUILDKITE_PULL_REQUEST)
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
// Travis CI: TRAVIS_PULL_REQUEST is "false" on non-PR builds.
|
|
1357
|
+
ci: "Travis CI",
|
|
1358
|
+
read: (env) => numericPrCandidate(env.TRAVIS_PULL_REQUEST)
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
// Drone: DRONE_PULL_REQUEST is the PR number, unset outside PR builds.
|
|
1362
|
+
ci: "Drone",
|
|
1363
|
+
read: (env) => numericPrCandidate(env.DRONE_PULL_REQUEST)
|
|
1364
|
+
}
|
|
1365
|
+
];
|
|
1366
|
+
function resolvePrNumberFromCiEnv(env) {
|
|
1367
|
+
for (const source of CI_PR_ENV_SOURCES) {
|
|
1368
|
+
const candidate = source.read(env);
|
|
1369
|
+
if (candidate !== void 0) return candidate;
|
|
1370
|
+
}
|
|
1371
|
+
return void 0;
|
|
1372
|
+
}
|
|
1373
|
+
function trailingNumber(value) {
|
|
1374
|
+
const match = value?.match(/(\d+)\/?$/);
|
|
1375
|
+
return match?.[1];
|
|
1376
|
+
}
|
|
1377
|
+
function numericPrCandidate(value) {
|
|
1378
|
+
if (!value || value === "false") return void 0;
|
|
1379
|
+
const parsed = Number(value);
|
|
1380
|
+
return Number.isInteger(parsed) && parsed > 0 ? value : void 0;
|
|
1381
|
+
}
|
|
1382
|
+
function loadEventPayload(input, env = {}) {
|
|
1383
|
+
const read = input.readEventPayload ?? (() => readEventFile(env.GITHUB_EVENT_PATH));
|
|
1384
|
+
const raw = safeRead(read);
|
|
1385
|
+
if (!isRecord3(raw)) return {};
|
|
1386
|
+
const pullRequest = isRecord3(raw.pull_request) ? raw.pull_request : void 0;
|
|
1387
|
+
const head = isRecord3(pullRequest?.head) ? pullRequest.head : void 0;
|
|
1388
|
+
return {
|
|
1389
|
+
prNumber: numberOrUndefined(pullRequest?.number ?? raw.number),
|
|
1390
|
+
headSha: stringOrUndefined2(head?.sha)
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
function readEventFile(path) {
|
|
1394
|
+
if (!path) return void 0;
|
|
1395
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
1396
|
+
}
|
|
1397
|
+
function safeRead(read) {
|
|
1398
|
+
try {
|
|
1399
|
+
return read();
|
|
1400
|
+
} catch {
|
|
1401
|
+
return void 0;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function isRecord3(value) {
|
|
1405
|
+
return typeof value === "object" && value !== null;
|
|
1406
|
+
}
|
|
1407
|
+
function numberOrUndefined(value) {
|
|
1408
|
+
return typeof value === "number" ? value : void 0;
|
|
1409
|
+
}
|
|
1410
|
+
function stringOrUndefined2(value) {
|
|
1411
|
+
return typeof value === "string" ? value : void 0;
|
|
1412
|
+
}
|
|
1413
|
+
function describeContextTarget(context) {
|
|
1414
|
+
if (context.prNumber !== void 0) return `${context.repo}#${context.prNumber}`;
|
|
1415
|
+
if (context.headSha) return `${context.repo}@${context.headSha}`;
|
|
1416
|
+
return context.repo;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/legacy-server.ts
|
|
1420
|
+
var LEGACY_SERVER_BAD_REQUEST_STATUS = 400;
|
|
1421
|
+
function isUnresolvedPrOnLegacyServer(options) {
|
|
1422
|
+
return !options.prNumberKnown && options.status === LEGACY_SERVER_BAD_REQUEST_STATUS && !options.code;
|
|
1423
|
+
}
|
|
1424
|
+
var LEGACY_SERVER_PR_HINT = "if this is an older DataDisco server, pass --pr <number> explicitly";
|
|
1425
|
+
function withLegacyServerPrHint(error) {
|
|
1426
|
+
return `${error} (${LEGACY_SERVER_PR_HINT})`;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
227
1429
|
// src/verdict.ts
|
|
228
1430
|
var EXIT_CODE = {
|
|
229
1431
|
PASSED: 0,
|
|
@@ -301,7 +1503,7 @@ var POLL_INTERVAL_MS = 15e3;
|
|
|
301
1503
|
var MINUTE_MS = 6e4;
|
|
302
1504
|
async function waitForVerdict(api, context, deps, timeoutMs) {
|
|
303
1505
|
const deadline = deps.now() + timeoutMs;
|
|
304
|
-
deps.logger.info(`Waiting for QA verdict on ${context
|
|
1506
|
+
deps.logger.info(`Waiting for QA verdict on ${describeContextTarget(context)}\u2026`);
|
|
305
1507
|
let lastStatus;
|
|
306
1508
|
while (true) {
|
|
307
1509
|
const outcome = await api.getRunStatus(context);
|
|
@@ -316,7 +1518,9 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
|
|
|
316
1518
|
return verdict.exitCode;
|
|
317
1519
|
}
|
|
318
1520
|
} else if (outcome.status !== 404) {
|
|
319
|
-
deps.logger.error(
|
|
1521
|
+
deps.logger.error(
|
|
1522
|
+
`Could not read QA run status: ${describeRunStatusFailure(outcome, context)}`
|
|
1523
|
+
);
|
|
320
1524
|
return 1;
|
|
321
1525
|
}
|
|
322
1526
|
if (deps.now() >= deadline) {
|
|
@@ -328,6 +1532,16 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
|
|
|
328
1532
|
await deps.sleep(POLL_INTERVAL_MS);
|
|
329
1533
|
}
|
|
330
1534
|
}
|
|
1535
|
+
function describeRunStatusFailure(outcome, context) {
|
|
1536
|
+
if (isUnresolvedPrOnLegacyServer({
|
|
1537
|
+
prNumberKnown: context.prNumber !== void 0,
|
|
1538
|
+
status: outcome.status,
|
|
1539
|
+
code: outcome.code
|
|
1540
|
+
})) {
|
|
1541
|
+
return withLegacyServerPrHint(outcome.error);
|
|
1542
|
+
}
|
|
1543
|
+
return outcome.error;
|
|
1544
|
+
}
|
|
331
1545
|
var DEFAULT_TIMEOUT_FLAG = {
|
|
332
1546
|
flag: "--timeout",
|
|
333
1547
|
defaultMinutes: DEFAULT_TIMEOUT_MINUTES
|
|
@@ -359,7 +1573,7 @@ async function runReportPreview(options, deps = defaultDeps) {
|
|
|
359
1573
|
}
|
|
360
1574
|
assertValidPreviewUrl(options.url);
|
|
361
1575
|
const config = resolveConfig(options);
|
|
362
|
-
const context =
|
|
1576
|
+
const context = resolveRunContextAllowingShaOnly(options);
|
|
363
1577
|
const waitRunTimeoutMs = resolveTimeoutMs(options.waitRunTimeout, WAIT_RUN_TIMEOUT_SPEC);
|
|
364
1578
|
const api = deps.createApi(config);
|
|
365
1579
|
const outcome = await reportPreviewUntilRunExists(api, context, options.url, deps, {
|
|
@@ -367,11 +1581,11 @@ async function runReportPreview(options, deps = defaultDeps) {
|
|
|
367
1581
|
waitRunTimeoutMs
|
|
368
1582
|
});
|
|
369
1583
|
if (!outcome.ok) {
|
|
370
|
-
deps.logger.error(`Could not report preview URL: ${describeFailure(outcome)}`);
|
|
1584
|
+
deps.logger.error(`Could not report preview URL: ${describeFailure(outcome, context)}`);
|
|
371
1585
|
return 1;
|
|
372
1586
|
}
|
|
373
1587
|
deps.logger.success(
|
|
374
|
-
`Preview reported for ${context
|
|
1588
|
+
`Preview reported for ${describeContextTarget(context)} \u2014 QA run ${outcome.runId} queued.`
|
|
375
1589
|
);
|
|
376
1590
|
return 0;
|
|
377
1591
|
}
|
|
@@ -385,7 +1599,14 @@ async function reportPreviewUntilRunExists(api, context, url, deps, policy) {
|
|
|
385
1599
|
function isRunNotCreatedYet(outcome) {
|
|
386
1600
|
return !outcome.ok && outcome.status === RUN_NOT_CREATED_YET_STATUS && outcome.retryable !== false;
|
|
387
1601
|
}
|
|
388
|
-
function describeFailure(outcome) {
|
|
1602
|
+
function describeFailure(outcome, context) {
|
|
1603
|
+
if (isUnresolvedPrOnLegacyServer({
|
|
1604
|
+
prNumberKnown: context.prNumber !== void 0,
|
|
1605
|
+
status: outcome.status,
|
|
1606
|
+
code: outcome.code
|
|
1607
|
+
})) {
|
|
1608
|
+
return withLegacyServerPrHint(outcome.error);
|
|
1609
|
+
}
|
|
389
1610
|
return outcome.code ? `${outcome.error} [${outcome.code}]` : outcome.error;
|
|
390
1611
|
}
|
|
391
1612
|
async function retryReportPreviewUntilRunExists(api, context, url, deps, waitRunTimeoutMs) {
|
|
@@ -410,28 +1631,6 @@ function withRunNeverAppearedHint(error) {
|
|
|
410
1631
|
return `${error} (gave up waiting \u2014 the QA run never appeared)`;
|
|
411
1632
|
}
|
|
412
1633
|
|
|
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
1634
|
// src/tunnel-provider.ts
|
|
436
1635
|
import localtunnel from "localtunnel";
|
|
437
1636
|
async function openTunnel(port) {
|
|
@@ -498,6 +1697,16 @@ async function runWait(options, deps = defaultDeps) {
|
|
|
498
1697
|
|
|
499
1698
|
// src/cli.ts
|
|
500
1699
|
var cli = cac("datadisco-qa");
|
|
1700
|
+
cli.command("init", "Sign in, connect this repo, and set up preview reporting").option("--yes", "Skip prompts: never wait, never overwrite, take the defaults").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--api-url <url>", "DataDisco API base URL").option(
|
|
1701
|
+
"--api-token <token>",
|
|
1702
|
+
"Workspace API token (or DATADISCO_API_TOKEN, or the saved sign-in)"
|
|
1703
|
+
).option("--no-browser", "Print URLs instead of opening them in a browser").action((options) => run(() => runInit(options)));
|
|
1704
|
+
cli.command("doctor", "Check the sign-in, repo connection, preview source, workflow, and secret").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--api-url <url>", "DataDisco API base URL").option(
|
|
1705
|
+
"--api-token <token>",
|
|
1706
|
+
"Workspace API token (or DATADISCO_API_TOKEN, or the saved sign-in)"
|
|
1707
|
+
).action((options) => run(() => runDoctor(options)));
|
|
1708
|
+
cli.command("login", "Sign in to DataDisco from this machine and save the token").option("--api-url <url>", "DataDisco API base URL").option("--no-browser", "Print the verification URL instead of opening it").action((options) => run(() => runLogin(options)));
|
|
1709
|
+
cli.command("logout", "Forget the saved sign-in for the API URL").option("--api-url <url>", "DataDisco API base URL").action((options) => run(() => runLogout(options)));
|
|
501
1710
|
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
1711
|
"--no-wait-for-run",
|
|
503
1712
|
"Fail immediately if the QA run hasn't been created yet, instead of retrying"
|
|
@@ -505,12 +1714,16 @@ cli.command("report-preview", "Report a PR's preview-deploy URL so its pending Q
|
|
|
505
1714
|
"--wait-run-timeout <minutes>",
|
|
506
1715
|
"How long to retry while the QA run hasn't been created yet (default 12)"
|
|
507
1716
|
).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)));
|
|
1717
|
+
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
1718
|
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
1719
|
async function run(command) {
|
|
511
1720
|
try {
|
|
512
1721
|
process.exitCode = await command();
|
|
513
1722
|
} catch (error) {
|
|
1723
|
+
if (error instanceof PromptCancelledError) {
|
|
1724
|
+
process.exitCode = 130;
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
514
1727
|
if (error instanceof ConfigError) {
|
|
515
1728
|
console.error(pc2.red(error.message));
|
|
516
1729
|
process.exitCode = 1;
|