@datadisco/qa 0.3.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 +121 -6
- package/dist/cli.js +1266 -154
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,58 +10,1271 @@ 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;
|
|
31
|
+
}
|
|
32
|
+
function assertValidPreviewUrl(url) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(url);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new ConfigError(`Preview url is not a valid URL: "${url}".`);
|
|
38
|
+
}
|
|
39
|
+
if (parsed.protocol !== "https:") {
|
|
40
|
+
throw new ConfigError(`Preview url must be https, got "${url}".`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
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";
|
|
120
|
+
return {
|
|
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)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
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;
|
|
214
|
+
return {
|
|
215
|
+
token: value.token,
|
|
216
|
+
workspaceName: stringOr(value.workspaceName, ""),
|
|
217
|
+
workspaceSlug: stringOr(value.workspaceSlug, ""),
|
|
218
|
+
savedAt: stringOr(value.savedAt, "")
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function parseJson(raw) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return void 0;
|
|
226
|
+
}
|
|
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) };
|
|
246
|
+
}
|
|
247
|
+
await announcePairing(started.pairing, deps, options);
|
|
248
|
+
return pollUntilResolved(api, started.pairing, deps);
|
|
249
|
+
}
|
|
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);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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)) {
|
|
371
|
+
throw new ConfigError(
|
|
372
|
+
`Preview URL pattern must contain ${PATTERN_PLACEHOLDER}, e.g. https://pr-${PATTERN_PLACEHOLDER}.preview.example.com`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function describeRecipe(pattern) {
|
|
377
|
+
return `report-preview on pull_request using ${pattern}`;
|
|
378
|
+
}
|
|
379
|
+
function triggersOnDeploymentStatus(workflow) {
|
|
380
|
+
return DEPLOYMENT_STATUS_TRIGGER.test(workflow);
|
|
381
|
+
}
|
|
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
|
+
`;
|
|
403
|
+
}
|
|
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) {
|
|
429
|
+
try {
|
|
430
|
+
return readFileSync(path, "utf8");
|
|
431
|
+
} catch {
|
|
432
|
+
return void 0;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function tryList(path) {
|
|
436
|
+
try {
|
|
437
|
+
return readdirSync(path);
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
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;
|
|
449
|
+
}
|
|
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();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/api.ts
|
|
582
|
+
var REPORT_PREVIEW_PATH = "/api/qa/report-preview";
|
|
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";
|
|
587
|
+
function createApiClient(config, fetchImpl = fetch) {
|
|
588
|
+
return {
|
|
589
|
+
reportPreview: (params) => reportPreview(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)
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
async function reportPreview(config, params, fetchImpl) {
|
|
597
|
+
const response = await fetchImpl(joinUrl(config.apiUrl, REPORT_PREVIEW_PATH), {
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: { ...authHeaders(config), "content-type": "application/json" },
|
|
600
|
+
body: JSON.stringify({
|
|
601
|
+
repo: params.repo,
|
|
602
|
+
prNumber: params.prNumber,
|
|
603
|
+
headSha: params.headSha,
|
|
604
|
+
url: params.url
|
|
605
|
+
})
|
|
606
|
+
});
|
|
607
|
+
const payload = await readJson(response);
|
|
608
|
+
if (response.ok && isString(payload.runId)) {
|
|
609
|
+
return { ok: true, runId: payload.runId };
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
...failure(response, payload),
|
|
613
|
+
retryable: booleanOrUndefined(payload.retryable)
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
async function getRunStatus(config, params, fetchImpl) {
|
|
617
|
+
const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
|
|
618
|
+
url.searchParams.set("repo", params.repo);
|
|
619
|
+
setRunLookupParam(url, params);
|
|
620
|
+
const response = await fetchImpl(url.toString(), { headers: authHeaders(config) });
|
|
621
|
+
const payload = await readJson(response);
|
|
622
|
+
if (response.ok && isRunStatus(payload.run)) {
|
|
623
|
+
return { ok: true, run: payload.run };
|
|
624
|
+
}
|
|
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}` } : {};
|
|
678
|
+
}
|
|
679
|
+
function joinUrl(base, path) {
|
|
680
|
+
return `${base.replace(/\/+$/, "")}${path}`;
|
|
681
|
+
}
|
|
682
|
+
async function readJson(response) {
|
|
683
|
+
try {
|
|
684
|
+
const parsed = await response.json();
|
|
685
|
+
return isRecord2(parsed) ? parsed : {};
|
|
686
|
+
} catch {
|
|
687
|
+
return {};
|
|
688
|
+
}
|
|
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
|
+
}
|
|
698
|
+
function errorMessage(payload) {
|
|
699
|
+
return isString(payload.error) ? payload.error : "Request failed.";
|
|
700
|
+
}
|
|
701
|
+
function isRecord2(value) {
|
|
702
|
+
return typeof value === "object" && value !== null;
|
|
703
|
+
}
|
|
704
|
+
function isString(value) {
|
|
705
|
+
return typeof value === "string";
|
|
706
|
+
}
|
|
707
|
+
function stringOrUndefined(value) {
|
|
708
|
+
return isString(value) ? value : void 0;
|
|
709
|
+
}
|
|
710
|
+
function stringOrNull(value) {
|
|
711
|
+
return isString(value) ? value : null;
|
|
712
|
+
}
|
|
713
|
+
function numberOrNull(value) {
|
|
714
|
+
return typeof value === "number" ? value : null;
|
|
715
|
+
}
|
|
716
|
+
function booleanOrUndefined(value) {
|
|
717
|
+
return typeof value === "boolean" ? value : void 0;
|
|
718
|
+
}
|
|
719
|
+
function booleanOr(value, fallback) {
|
|
720
|
+
return typeof value === "boolean" ? value : fallback;
|
|
721
|
+
}
|
|
722
|
+
function isRunStatus(value) {
|
|
723
|
+
return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
|
|
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
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/logger.ts
|
|
806
|
+
import pc from "picocolors";
|
|
807
|
+
var consoleLogger = {
|
|
808
|
+
info: (message) => console.error(message),
|
|
809
|
+
success: (message) => console.error(pc.green(message)),
|
|
810
|
+
warn: (message) => console.error(pc.yellow(message)),
|
|
811
|
+
error: (message) => console.error(pc.red(message))
|
|
812
|
+
};
|
|
813
|
+
var LEVEL_LOGGERS = {
|
|
814
|
+
success: (logger, message) => logger.success(message),
|
|
815
|
+
warn: (logger, message) => logger.warn(message),
|
|
816
|
+
error: (logger, message) => logger.error(message)
|
|
817
|
+
};
|
|
818
|
+
function logAtLevel(logger, level, message) {
|
|
819
|
+
LEVEL_LOGGERS[level](logger, message);
|
|
23
820
|
}
|
|
24
|
-
|
|
25
|
-
|
|
821
|
+
|
|
822
|
+
// src/commands/deps.ts
|
|
823
|
+
var defaultDeps = {
|
|
824
|
+
createApi: (config) => createApiClient(config),
|
|
825
|
+
logger: consoleLogger,
|
|
826
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
827
|
+
now: () => Date.now()
|
|
828
|
+
};
|
|
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) {
|
|
26
846
|
try {
|
|
27
|
-
|
|
847
|
+
return lookup().username;
|
|
28
848
|
} catch {
|
|
29
|
-
|
|
849
|
+
return process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
30
850
|
}
|
|
31
|
-
|
|
32
|
-
|
|
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) };
|
|
33
863
|
}
|
|
34
864
|
}
|
|
35
865
|
|
|
36
|
-
// src/
|
|
37
|
-
|
|
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
|
+
}
|
|
38
1010
|
|
|
39
|
-
// src/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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;
|
|
45
1041
|
}
|
|
46
|
-
function
|
|
47
|
-
|
|
1042
|
+
function resolveRepo(options, deps) {
|
|
1043
|
+
const repo = options.repo ?? deps.detectRepo();
|
|
1044
|
+
if (!repo) throw new ConfigError(REPO_MISSING_MESSAGE);
|
|
1045
|
+
return repo;
|
|
48
1046
|
}
|
|
49
|
-
function
|
|
50
|
-
const
|
|
51
|
-
|
|
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);
|
|
52
1067
|
}
|
|
53
|
-
function
|
|
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) {
|
|
54
1090
|
try {
|
|
55
|
-
return
|
|
56
|
-
} catch {
|
|
57
|
-
return
|
|
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;
|
|
58
1268
|
}
|
|
1269
|
+
deps.logger.success(`Signed out of ${apiUrl} (removed from ${deps.configFilePath}).`);
|
|
1270
|
+
return 0;
|
|
59
1271
|
}
|
|
60
1272
|
|
|
61
1273
|
// src/context.ts
|
|
1274
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
62
1275
|
var REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;
|
|
63
1276
|
var PR_REF_PATTERN = /^refs\/pull\/(\d+)\//;
|
|
64
|
-
var
|
|
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).";
|
|
65
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).";
|
|
66
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).";
|
|
67
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).";
|
|
@@ -80,7 +1293,7 @@ function resolveRunContextAllowingShaOnly(input = {}) {
|
|
|
80
1293
|
throw new ConfigError(HEAD_SHA_MISSING_MESSAGE);
|
|
81
1294
|
}
|
|
82
1295
|
return {
|
|
83
|
-
repo:
|
|
1296
|
+
repo: resolveRepo2(input, env),
|
|
84
1297
|
prNumber: resolvePrNumber(input, env, payload),
|
|
85
1298
|
headSha
|
|
86
1299
|
};
|
|
@@ -94,12 +1307,12 @@ function resolvePrContext(input = {}) {
|
|
|
94
1307
|
if (prNumber === void 0 && !headSha) {
|
|
95
1308
|
throw new ConfigError(PR_OR_SHA_MISSING_MESSAGE);
|
|
96
1309
|
}
|
|
97
|
-
return { repo:
|
|
1310
|
+
return { repo: resolveRepo2(input, env), prNumber, headSha };
|
|
98
1311
|
}
|
|
99
|
-
function
|
|
1312
|
+
function resolveRepo2(input, env = {}) {
|
|
100
1313
|
const repo = input.repo ?? env.GITHUB_REPOSITORY ?? detectRepoFullName(input.gitRunner);
|
|
101
1314
|
if (!repo) {
|
|
102
|
-
throw new ConfigError(
|
|
1315
|
+
throw new ConfigError(REPO_MISSING_MESSAGE2);
|
|
103
1316
|
}
|
|
104
1317
|
if (!REPO_PATTERN.test(repo)) {
|
|
105
1318
|
throw new ConfigError(`--repo must look like "owner/name", got "${repo}".`);
|
|
@@ -169,17 +1382,17 @@ function numericPrCandidate(value) {
|
|
|
169
1382
|
function loadEventPayload(input, env = {}) {
|
|
170
1383
|
const read = input.readEventPayload ?? (() => readEventFile(env.GITHUB_EVENT_PATH));
|
|
171
1384
|
const raw = safeRead(read);
|
|
172
|
-
if (!
|
|
173
|
-
const pullRequest =
|
|
174
|
-
const head =
|
|
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;
|
|
175
1388
|
return {
|
|
176
1389
|
prNumber: numberOrUndefined(pullRequest?.number ?? raw.number),
|
|
177
|
-
headSha:
|
|
1390
|
+
headSha: stringOrUndefined2(head?.sha)
|
|
178
1391
|
};
|
|
179
1392
|
}
|
|
180
1393
|
function readEventFile(path) {
|
|
181
1394
|
if (!path) return void 0;
|
|
182
|
-
return JSON.parse(
|
|
1395
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
183
1396
|
}
|
|
184
1397
|
function safeRead(read) {
|
|
185
1398
|
try {
|
|
@@ -188,13 +1401,13 @@ function safeRead(read) {
|
|
|
188
1401
|
return void 0;
|
|
189
1402
|
}
|
|
190
1403
|
}
|
|
191
|
-
function
|
|
1404
|
+
function isRecord3(value) {
|
|
192
1405
|
return typeof value === "object" && value !== null;
|
|
193
1406
|
}
|
|
194
1407
|
function numberOrUndefined(value) {
|
|
195
1408
|
return typeof value === "number" ? value : void 0;
|
|
196
1409
|
}
|
|
197
|
-
function
|
|
1410
|
+
function stringOrUndefined2(value) {
|
|
198
1411
|
return typeof value === "string" ? value : void 0;
|
|
199
1412
|
}
|
|
200
1413
|
function describeContextTarget(context) {
|
|
@@ -213,121 +1426,6 @@ function withLegacyServerPrHint(error) {
|
|
|
213
1426
|
return `${error} (${LEGACY_SERVER_PR_HINT})`;
|
|
214
1427
|
}
|
|
215
1428
|
|
|
216
|
-
// src/api.ts
|
|
217
|
-
var REPORT_PREVIEW_PATH = "/api/qa/report-preview";
|
|
218
|
-
var RUN_STATUS_PATH = "/api/qa/run-status";
|
|
219
|
-
function createApiClient(config, fetchImpl = fetch) {
|
|
220
|
-
return {
|
|
221
|
-
reportPreview: (params) => reportPreview(config, params, fetchImpl),
|
|
222
|
-
getRunStatus: (params) => getRunStatus(config, params, fetchImpl)
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
async function reportPreview(config, params, fetchImpl) {
|
|
226
|
-
const response = await fetchImpl(joinUrl(config.apiUrl, REPORT_PREVIEW_PATH), {
|
|
227
|
-
method: "POST",
|
|
228
|
-
headers: {
|
|
229
|
-
authorization: `Bearer ${config.apiToken}`,
|
|
230
|
-
"content-type": "application/json"
|
|
231
|
-
},
|
|
232
|
-
body: JSON.stringify({
|
|
233
|
-
repo: params.repo,
|
|
234
|
-
prNumber: params.prNumber,
|
|
235
|
-
headSha: params.headSha,
|
|
236
|
-
url: params.url
|
|
237
|
-
})
|
|
238
|
-
});
|
|
239
|
-
const payload = await readJson(response);
|
|
240
|
-
if (response.ok && isString(payload.runId)) {
|
|
241
|
-
return { ok: true, runId: payload.runId };
|
|
242
|
-
}
|
|
243
|
-
return {
|
|
244
|
-
ok: false,
|
|
245
|
-
status: response.status,
|
|
246
|
-
error: errorMessage(payload),
|
|
247
|
-
retryable: booleanOrUndefined(payload.retryable),
|
|
248
|
-
code: stringOrUndefined2(payload.code)
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
async function getRunStatus(config, params, fetchImpl) {
|
|
252
|
-
const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
|
|
253
|
-
url.searchParams.set("repo", params.repo);
|
|
254
|
-
setRunLookupParam(url, params);
|
|
255
|
-
const response = await fetchImpl(url.toString(), {
|
|
256
|
-
headers: { authorization: `Bearer ${config.apiToken}` }
|
|
257
|
-
});
|
|
258
|
-
const payload = await readJson(response);
|
|
259
|
-
if (response.ok && isRunStatus(payload.run)) {
|
|
260
|
-
return { ok: true, run: payload.run };
|
|
261
|
-
}
|
|
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
|
-
}
|
|
275
|
-
}
|
|
276
|
-
function joinUrl(base, path) {
|
|
277
|
-
return `${base.replace(/\/+$/, "")}${path}`;
|
|
278
|
-
}
|
|
279
|
-
async function readJson(response) {
|
|
280
|
-
try {
|
|
281
|
-
const parsed = await response.json();
|
|
282
|
-
return isRecord2(parsed) ? parsed : {};
|
|
283
|
-
} catch {
|
|
284
|
-
return {};
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
function errorMessage(payload) {
|
|
288
|
-
return isString(payload.error) ? payload.error : "Request failed.";
|
|
289
|
-
}
|
|
290
|
-
function isRecord2(value) {
|
|
291
|
-
return typeof value === "object" && value !== null;
|
|
292
|
-
}
|
|
293
|
-
function isString(value) {
|
|
294
|
-
return typeof value === "string";
|
|
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
|
-
}
|
|
302
|
-
function isRunStatus(value) {
|
|
303
|
-
return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// src/logger.ts
|
|
307
|
-
import pc from "picocolors";
|
|
308
|
-
var consoleLogger = {
|
|
309
|
-
info: (message) => console.error(message),
|
|
310
|
-
success: (message) => console.error(pc.green(message)),
|
|
311
|
-
warn: (message) => console.error(pc.yellow(message)),
|
|
312
|
-
error: (message) => console.error(pc.red(message))
|
|
313
|
-
};
|
|
314
|
-
var LEVEL_LOGGERS = {
|
|
315
|
-
success: (logger, message) => logger.success(message),
|
|
316
|
-
warn: (logger, message) => logger.warn(message),
|
|
317
|
-
error: (logger, message) => logger.error(message)
|
|
318
|
-
};
|
|
319
|
-
function logAtLevel(logger, level, message) {
|
|
320
|
-
LEVEL_LOGGERS[level](logger, message);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// src/commands/deps.ts
|
|
324
|
-
var defaultDeps = {
|
|
325
|
-
createApi: (config) => createApiClient(config),
|
|
326
|
-
logger: consoleLogger,
|
|
327
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
328
|
-
now: () => Date.now()
|
|
329
|
-
};
|
|
330
|
-
|
|
331
1429
|
// src/verdict.ts
|
|
332
1430
|
var EXIT_CODE = {
|
|
333
1431
|
PASSED: 0,
|
|
@@ -599,6 +1697,16 @@ async function runWait(options, deps = defaultDeps) {
|
|
|
599
1697
|
|
|
600
1698
|
// src/cli.ts
|
|
601
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)));
|
|
602
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(
|
|
603
1711
|
"--no-wait-for-run",
|
|
604
1712
|
"Fail immediately if the QA run hasn't been created yet, instead of retrying"
|
|
@@ -612,6 +1720,10 @@ async function run(command) {
|
|
|
612
1720
|
try {
|
|
613
1721
|
process.exitCode = await command();
|
|
614
1722
|
} catch (error) {
|
|
1723
|
+
if (error instanceof PromptCancelledError) {
|
|
1724
|
+
process.exitCode = 130;
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
615
1727
|
if (error instanceof ConfigError) {
|
|
616
1728
|
console.error(pc2.red(error.message));
|
|
617
1729
|
process.exitCode = 1;
|