@snapvisor/cli 6.4.1 → 7.1.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/LICENSE +8 -8
- package/README.md +63 -45
- package/bin/argos-cli.js +3 -3
- package/dist/index.mjs +640 -439
- package/package.json +7 -7
package/dist/index.mjs
CHANGED
|
@@ -1,131 +1,20 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { Option, program } from "commander";
|
|
5
|
-
import { deploy, finalize, skip, upload } from "@snapvisor/core";
|
|
4
|
+
import { InvalidArgumentError, Option, program } from "commander";
|
|
5
|
+
import { deploy, finalize, readConfig, skip, upload } from "@snapvisor/core";
|
|
6
6
|
import ora from "ora";
|
|
7
7
|
import { APIError, createClient, throwAPIError } from "@snapvisor/api-client";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
11
|
import open from "open";
|
|
12
|
-
//#region src/options.ts
|
|
13
|
-
const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build");
|
|
14
|
-
const tokenOption = new Option("--token <token>", "Repository token");
|
|
15
|
-
const projectOption = new Option("--project <slug>", "Snapvisor project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository");
|
|
16
|
-
const buildNameOption = new Option("--build-name <string>", "Name of the build, in case you want to run multiple Snapvisor builds in a single CI job");
|
|
17
|
-
const accountOption = new Option("--account <slug>", "Slug of the account (personal or team) that will own the project");
|
|
18
|
-
const jsonOption = new Option("--json", "Output machine-readable JSON instead of human-readable text");
|
|
19
|
-
const projectPathOption = new Option("--project <owner/project>", "Project path in owner/project format. Required for build-number references on review and comment commands");
|
|
20
|
-
const changeProjectPathOption = new Option("--project <owner/project>", "Project the change belongs to, in owner/project format. Also ARGOS_PROJECT");
|
|
21
|
-
const METRICS_PERIOD_BY_CHOICE = {
|
|
22
|
-
"24h": "LAST_24_HOURS",
|
|
23
|
-
"3d": "LAST_3_DAYS",
|
|
24
|
-
"7d": "LAST_7_DAYS",
|
|
25
|
-
"30d": "LAST_30_DAYS",
|
|
26
|
-
"90d": "LAST_90_DAYS"
|
|
27
|
-
};
|
|
28
|
-
const metricsPeriodOption = new Option("--metrics-period <period>", "Window over which test flakiness metrics are computed").choices(Object.keys(METRICS_PERIOD_BY_CHOICE)).default("7d");
|
|
29
|
-
/** Map a `--metrics-period` choice to the value the API expects. */
|
|
30
|
-
function toMetricsPeriod(choice) {
|
|
31
|
-
return METRICS_PERIOD_BY_CHOICE[choice] ?? "LAST_7_DAYS";
|
|
32
|
-
}
|
|
33
|
-
//#endregion
|
|
34
|
-
//#region src/commands/upload.ts
|
|
35
|
-
function uploadCommand(program) {
|
|
36
|
-
program.command("upload").argument("<directory>", "Directory to upload").description("Upload screenshots to Snapvisor").option("-f, --files <patterns...>", "One or more globs matching image file paths to upload", "**/*.{png,jpg,jpeg}").option("-i, --ignore <patterns...>", "One or more globs matching image file paths to ignore (ex: \"**/*.png **/diff.jpg\")").addOption(tokenOption).addOption(projectOption).addOption(buildNameOption).addOption(new Option("--mode <string>", "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.").default("ci").choices(["ci", "monitoring"])).addOption(new Option("--parallel", "Enable parallel mode. Run multiple Argos builds and combine them at the end.\nCan also be set via the ARGOS_PARALLEL environment variable.")).addOption(new Option("--parallel-total <number>", "The number of parallel nodes being ran")).addOption(parallelNonceOption).addOption(new Option("--parallel-index <number>", "The index of the parallel node being ran (must be at least 1)")).addOption(new Option("--reference-branch <string>", "Branch used as baseline for screenshot comparison")).addOption(new Option("--reference-commit <string>", "Commit used as baseline for screenshot comparison")).addOption(new Option("--threshold <number>", "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5")).addOption(new Option("--subset", "Whether this build contains only a subset of screenshots.\nThis is useful when a build is created from an incomplete test suite where some tests are skipped.\nCan also be set via the ARGOS_SUBSET environment variable.")).action(async (directory, options) => {
|
|
37
|
-
const spinner = ora("Uploading screenshots").start();
|
|
38
|
-
try {
|
|
39
|
-
const parallel = (() => {
|
|
40
|
-
if (!options.parallel) return;
|
|
41
|
-
if (!options.parallelNonce) {
|
|
42
|
-
spinner.fail("--parallel-nonce is required if --parallel is set");
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
if (!options.parallelTotal) {
|
|
46
|
-
spinner.fail("--parallel-total is required if --parallel is set");
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
nonce: options.parallelNonce,
|
|
51
|
-
total: options.parallelTotal,
|
|
52
|
-
index: options.parallelIndex
|
|
53
|
-
};
|
|
54
|
-
})();
|
|
55
|
-
const result = await upload({
|
|
56
|
-
token: options.token,
|
|
57
|
-
project: options.project,
|
|
58
|
-
root: directory,
|
|
59
|
-
buildName: options.buildName,
|
|
60
|
-
files: options.files,
|
|
61
|
-
ignore: options.ignore,
|
|
62
|
-
parallel,
|
|
63
|
-
referenceBranch: options.referenceBranch,
|
|
64
|
-
referenceCommit: options.referenceCommit,
|
|
65
|
-
mode: options.mode,
|
|
66
|
-
threshold: options.threshold,
|
|
67
|
-
subset: options.subset
|
|
68
|
-
});
|
|
69
|
-
spinner.succeed(`Build created: ${result.build.url}`);
|
|
70
|
-
} catch (error) {
|
|
71
|
-
if (error instanceof Error) {
|
|
72
|
-
spinner.fail(`Build failed: ${error.message}`);
|
|
73
|
-
console.error(error.stack);
|
|
74
|
-
}
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
//#endregion
|
|
80
|
-
//#region src/commands/finalize.ts
|
|
81
|
-
const skipIfEmptyOption = new Option("--skip-if-empty", "Create a skipped build when no parallel build matches the nonce.\nUseful when uploads are conditional (e.g. skipped by a CI cache) and Argos is a required status check: the skipped build reports a success status even though nothing was uploaded.");
|
|
82
|
-
function finalizeCommand(program) {
|
|
83
|
-
program.command("finalize").description("Finalize pending parallel builds").addOption(parallelNonceOption).addOption(tokenOption).addOption(skipIfEmptyOption).addOption(buildNameOption).action(async (options) => {
|
|
84
|
-
const spinner = ora("Finalizing builds").start();
|
|
85
|
-
try {
|
|
86
|
-
const result = await finalize({
|
|
87
|
-
token: options.token,
|
|
88
|
-
parallel: options.parallelNonce ? { nonce: options.parallelNonce } : void 0,
|
|
89
|
-
skipIfEmpty: options.skipIfEmpty,
|
|
90
|
-
buildName: options.buildName
|
|
91
|
-
});
|
|
92
|
-
spinner.succeed(result.skippedBuild ? `No builds to finalize — skipped build created: ${result.skippedBuild.url}` : result.builds.length === 0 ? "No builds to finalize" : `Builds finalized: ${result.builds.map((b) => b.url).join(", ")}`);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
if (error instanceof Error) {
|
|
95
|
-
spinner.fail(`Failed to finalize: ${error.message}`);
|
|
96
|
-
console.error(error.stack);
|
|
97
|
-
}
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
//#endregion
|
|
103
|
-
//#region src/commands/skip.ts
|
|
104
|
-
function skipCommand(program) {
|
|
105
|
-
program.command("skip").description("Mark a build as skipped").addOption(tokenOption).addOption(buildNameOption).action(async (options) => {
|
|
106
|
-
const spinner = ora("Creating skipped build").start();
|
|
107
|
-
try {
|
|
108
|
-
const result = await skip({
|
|
109
|
-
token: options.token,
|
|
110
|
-
buildName: options.buildName
|
|
111
|
-
});
|
|
112
|
-
spinner.succeed(`Build created: ${result.build.url}`);
|
|
113
|
-
} catch (error) {
|
|
114
|
-
if (error instanceof Error) {
|
|
115
|
-
spinner.fail(`Build failed: ${error.message}`);
|
|
116
|
-
console.error(error.stack);
|
|
117
|
-
}
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
//#endregion
|
|
123
12
|
//#region src/lib/api.ts
|
|
124
13
|
/** Optional override for the API base URL (used in tests and dev). */
|
|
125
14
|
function getApiBaseUrl() {
|
|
126
15
|
return process.env["ARGOS_API_BASE_URL"];
|
|
127
16
|
}
|
|
128
|
-
/** Create an
|
|
17
|
+
/** Create an Snapvisor API client authenticated with the given token. */
|
|
129
18
|
function createApiClient(authToken) {
|
|
130
19
|
return createClient({
|
|
131
20
|
authToken,
|
|
@@ -142,314 +31,106 @@ function unwrap(result) {
|
|
|
142
31
|
return result.data;
|
|
143
32
|
}
|
|
144
33
|
//#endregion
|
|
145
|
-
//#region src/lib/
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
34
|
+
//#region src/lib/cli-error.ts
|
|
35
|
+
/**
|
|
36
|
+
* Error whose message is meant to be shown to the user verbatim (prefixed with
|
|
37
|
+
* `Error: `) before the CLI exits with a non-zero code. Throw it — via
|
|
38
|
+
* {@link fail} — instead of calling `process.exit` directly so the failure can
|
|
39
|
+
* be unit-tested and handled in a single place ({@link handleCliError}).
|
|
40
|
+
*/
|
|
41
|
+
var CliError = class extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "CliError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Abort the current command with a user-facing message.
|
|
49
|
+
*/
|
|
50
|
+
function fail(message) {
|
|
51
|
+
throw new CliError(message);
|
|
150
52
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/lib/oauth.ts
|
|
55
|
+
/**
|
|
56
|
+
* OAuth 2.1 client configuration and helpers for the `argos login` flow
|
|
57
|
+
* (Authorization Code + PKCE with a loopback redirect, RFC 8252).
|
|
58
|
+
*
|
|
59
|
+
* The authorization server is the Snapvisor app origin; endpoints and token
|
|
60
|
+
* validation are implemented in the main Snapvisor repo (`apps/backend/src/oauth`).
|
|
61
|
+
*/
|
|
62
|
+
/** First-party public client id, seeded in the Snapvisor database. */
|
|
63
|
+
const OAUTH_CLIENT_ID = "argos-cli";
|
|
64
|
+
/**
|
|
65
|
+
* Scopes requested by `argos login`, covering the CLI's user actions (identity,
|
|
66
|
+
* projects, reviews, comments). Build upload keeps using a project token.
|
|
67
|
+
*/
|
|
68
|
+
const OAUTH_SCOPES = [
|
|
69
|
+
"profile",
|
|
70
|
+
"projects:read",
|
|
71
|
+
"projects:write",
|
|
72
|
+
"reviews:write",
|
|
73
|
+
"comments:read",
|
|
74
|
+
"comments:write"
|
|
75
|
+
];
|
|
76
|
+
/** Refresh a bit early so a token never expires mid-request. */
|
|
77
|
+
const EXPIRY_SKEW_MS = 60 * 1e3;
|
|
78
|
+
function getAppBaseUrl() {
|
|
79
|
+
return process.env["ARGOS_APP_BASE_URL"] ?? "https://app.snapvisor.io/";
|
|
154
80
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
81
|
+
/**
|
|
82
|
+
* A token endpoint request that reached the server and was rejected by it
|
|
83
|
+
* (non-2xx / OAuth error), as opposed to a transient network failure. Lets
|
|
84
|
+
* callers tell "your session is gone, log in again" from "the network is down".
|
|
85
|
+
*/
|
|
86
|
+
var OAuthTokenError = class extends Error {
|
|
87
|
+
code;
|
|
88
|
+
constructor(message, code) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.name = "OAuthTokenError";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function toTokenSet(data) {
|
|
95
|
+
return {
|
|
96
|
+
accessToken: data.access_token,
|
|
97
|
+
refreshToken: data.refresh_token,
|
|
98
|
+
expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SKEW_MS,
|
|
99
|
+
scope: data.scope ?? ""
|
|
100
|
+
};
|
|
163
101
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
].join("\n");
|
|
102
|
+
/** Generate a PKCE `code_verifier` and its S256 `code_challenge`. */
|
|
103
|
+
function generatePkce() {
|
|
104
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
105
|
+
return {
|
|
106
|
+
codeVerifier,
|
|
107
|
+
codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url")
|
|
108
|
+
};
|
|
172
109
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
110
|
+
/** Build the `/oauth/authorize` URL to open in the browser. */
|
|
111
|
+
function buildAuthorizeUrl(params) {
|
|
112
|
+
const url = new URL("/oauth/authorize", getAppBaseUrl());
|
|
113
|
+
url.searchParams.set("response_type", "code");
|
|
114
|
+
url.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
115
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
116
|
+
url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
|
|
117
|
+
url.searchParams.set("state", params.state);
|
|
118
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
119
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
120
|
+
return url.href;
|
|
182
121
|
}
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
|
|
196
|
-
return SNAPSHOT_STATUS_ORDER.map((status) => {
|
|
197
|
-
const count = counts.get(status);
|
|
198
|
-
return count ? `${status} ${count}` : null;
|
|
199
|
-
}).filter((part) => Boolean(part)).join(", ");
|
|
200
|
-
}
|
|
201
|
-
function formatBuild(build) {
|
|
202
|
-
return [
|
|
203
|
-
`Build #${build.number}`,
|
|
204
|
-
`Status: ${build.status}`,
|
|
205
|
-
`Snapshots: ${formatStats(build.stats)}`,
|
|
206
|
-
`Conclusion: ${formatValue(build.conclusion)}`,
|
|
207
|
-
`Branch: ${formatValue(build.head?.branch)}`,
|
|
208
|
-
`Commit: ${formatValue(build.head?.sha)}`,
|
|
209
|
-
`Base branch: ${formatValue(build.base?.branch)}`,
|
|
210
|
-
`Base commit: ${formatValue(build.base?.sha)}`,
|
|
211
|
-
`URL: ${build.url}`
|
|
212
|
-
].join("\n");
|
|
213
|
-
}
|
|
214
|
-
/** Render a 0–1 metric to two decimals, or `-` when absent. */
|
|
215
|
-
function formatRatio(value) {
|
|
216
|
-
return value === null || value === void 0 ? "-" : value.toFixed(2);
|
|
217
|
-
}
|
|
218
|
-
/** Compact one-line summary of a test's flakiness metrics. */
|
|
219
|
-
function formatFlakiness(metrics) {
|
|
220
|
-
return `${formatRatio(metrics.flakiness)} (stability ${formatRatio(metrics.stability)}, consistency ${formatRatio(metrics.consistency)})`;
|
|
221
|
-
}
|
|
222
|
-
/** Human-readable lines for a single snapshot diff. */
|
|
223
|
-
function formatSnapshotDiff(diff, build) {
|
|
224
|
-
const lines = [
|
|
225
|
-
`${diff.name} [${diff.status}]`,
|
|
226
|
-
` Review: ${build.url}/${diff.id}`,
|
|
227
|
-
` Mask: ${formatValue(diff.url)}`,
|
|
228
|
-
` Base file: ${formatValue(diff.base?.url)}`,
|
|
229
|
-
` Head file: ${formatValue(diff.head?.url)}`,
|
|
230
|
-
` Score: ${formatValue(diff.score)}`,
|
|
231
|
-
` Group: ${formatValue(diff.group)}`
|
|
232
|
-
];
|
|
233
|
-
if (diff.test) lines.push(` Flakiness: ${formatFlakiness(diff.test.metrics)}`);
|
|
234
|
-
if (diff.change) {
|
|
235
|
-
const ignored = diff.change.ignored ? " [ignored]" : "";
|
|
236
|
-
lines.push(` Change: ${diff.change.id}${ignored} · ${diff.change.occurrences} occurrences`);
|
|
237
|
-
}
|
|
238
|
-
return lines;
|
|
239
|
-
}
|
|
240
|
-
function formatSnapshots(diffs, build) {
|
|
241
|
-
if (diffs.length === 0) return "No snapshots found.";
|
|
242
|
-
return [
|
|
243
|
-
`Snapshots for build #${build.number}`,
|
|
244
|
-
`Count: ${diffs.length}`,
|
|
245
|
-
`Summary: ${formatSnapshotSummary(diffs)}`,
|
|
246
|
-
"",
|
|
247
|
-
...diffs.flatMap((diff) => [...formatSnapshotDiff(diff, build), ""])
|
|
248
|
-
].slice(0, -1).join("\n");
|
|
249
|
-
}
|
|
250
|
-
function formatChange(change) {
|
|
251
|
-
return [
|
|
252
|
-
`Change ${change.id}`,
|
|
253
|
-
`Ignored: ${change.ignored ? "yes" : "no"}`,
|
|
254
|
-
`Occurrences: ${change.occurrences}`
|
|
255
|
-
].join("\n");
|
|
256
|
-
}
|
|
257
|
-
function formatReview(review) {
|
|
258
|
-
const lines = [
|
|
259
|
-
`Review #${review.id}`,
|
|
260
|
-
`State: ${review.state}`,
|
|
261
|
-
`Author: ${formatUser(review.user)}`,
|
|
262
|
-
`Date: ${review.date}`
|
|
263
|
-
];
|
|
264
|
-
if (review.dismissedAt) lines.push(`Dismissed: ${review.dismissedAt} by ${formatUser(review.dismissedBy)}`);
|
|
265
|
-
return lines.join("\n");
|
|
266
|
-
}
|
|
267
|
-
function formatReviews(reviews) {
|
|
268
|
-
if (reviews.length === 0) return "No reviews found.";
|
|
269
|
-
return [
|
|
270
|
-
`Reviews (${reviews.length})`,
|
|
271
|
-
"",
|
|
272
|
-
...reviews.flatMap((review) => [
|
|
273
|
-
`#${review.id} [${review.state}${review.dismissedAt ? ", dismissed" : ""}]`,
|
|
274
|
-
` Author: ${formatUser(review.user)}`,
|
|
275
|
-
` Date: ${review.date}`,
|
|
276
|
-
""
|
|
277
|
-
])
|
|
278
|
-
].slice(0, -1).join("\n");
|
|
279
|
-
}
|
|
280
|
-
function formatAnalytics(analytics, context) {
|
|
281
|
-
const { screenshots, builds } = analytics;
|
|
282
|
-
const lines = [
|
|
283
|
-
`Analytics for ${context.account}`,
|
|
284
|
-
`Period: ${context.from} → ${context.to ?? "now"} (grouped by ${context.groupBy})`,
|
|
285
|
-
"",
|
|
286
|
-
`Builds: ${builds.all.total}`,
|
|
287
|
-
` Changes detected: ${builds.all.changesDetected}`,
|
|
288
|
-
` No changes: ${builds.all.noChanges}`,
|
|
289
|
-
` Accepted: ${builds.all.accepted}`,
|
|
290
|
-
` Rejected: ${builds.all.rejected}`,
|
|
291
|
-
`Screenshots: ${screenshots.all.total}`
|
|
292
|
-
];
|
|
293
|
-
const names = /* @__PURE__ */ new Map();
|
|
294
|
-
for (const project of [...builds.projects, ...screenshots.projects]) names.set(project.id, project.name);
|
|
295
|
-
const ids = /* @__PURE__ */ new Set([...Object.keys(builds.all.projects), ...Object.keys(screenshots.all.projects)]);
|
|
296
|
-
if (ids.size > 0) {
|
|
297
|
-
const rows = [...ids].map((id) => ({
|
|
298
|
-
name: names.get(id) ?? id,
|
|
299
|
-
builds: builds.all.projects[id] ?? 0,
|
|
300
|
-
screenshots: screenshots.all.projects[id] ?? 0
|
|
301
|
-
})).sort((a, b) => b.builds - a.builds || b.screenshots - a.screenshots);
|
|
302
|
-
lines.push("", `Projects (${rows.length}):`);
|
|
303
|
-
for (const row of rows) lines.push(` ${row.name}: ${row.builds} builds, ${row.screenshots} screenshots`);
|
|
304
|
-
}
|
|
305
|
-
return lines.join("\n");
|
|
306
|
-
}
|
|
307
|
-
function indent(text) {
|
|
308
|
-
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
309
|
-
}
|
|
310
|
-
function formatAnchor(anchor) {
|
|
311
|
-
if (!anchor) return null;
|
|
312
|
-
return anchor.type === "point" ? `point ${anchor.x},${anchor.y}` : `lines ${anchor.from}-${anchor.to}`;
|
|
313
|
-
}
|
|
314
|
-
function formatReactions(reactions) {
|
|
315
|
-
if (reactions.length === 0) return null;
|
|
316
|
-
return reactions.map((r) => `${r.emoji} ${r.count}`).join(" ");
|
|
317
|
-
}
|
|
318
|
-
function formatComment(comment) {
|
|
319
|
-
const lines = [`Comment #${comment.id}`, `Author: ${formatUser(comment.author)}`];
|
|
320
|
-
if (comment.threadId) lines.push(`Reply to: ${comment.threadId}`);
|
|
321
|
-
if (comment.screenshotDiffId) {
|
|
322
|
-
const anchor = formatAnchor(comment.anchor);
|
|
323
|
-
lines.push(`Diff: ${comment.screenshotDiffId}${anchor ? ` (${anchor})` : ""}`);
|
|
324
|
-
}
|
|
325
|
-
if (comment.pending) lines.push("Pending: draft (only visible to you)");
|
|
326
|
-
if (comment.resolvedAt) lines.push(`Resolved: ${comment.resolvedAt}`);
|
|
327
|
-
const reactions = formatReactions(comment.reactions);
|
|
328
|
-
if (reactions) lines.push(`Reactions: ${reactions}`);
|
|
329
|
-
lines.push(`Date: ${comment.createdAt}${comment.editedAt ? " (edited)" : ""}`);
|
|
330
|
-
lines.push("", indent(comment.text));
|
|
331
|
-
return lines.join("\n");
|
|
332
|
-
}
|
|
333
|
-
function formatComments(comments) {
|
|
334
|
-
if (comments.length === 0) return "No comments found.";
|
|
335
|
-
return [
|
|
336
|
-
`Comments (${comments.length})`,
|
|
337
|
-
"",
|
|
338
|
-
...comments.flatMap((comment) => {
|
|
339
|
-
const tags = [
|
|
340
|
-
comment.threadId ? "reply" : "thread",
|
|
341
|
-
comment.pending ? "draft" : null,
|
|
342
|
-
comment.resolvedAt ? "resolved" : null
|
|
343
|
-
].filter(Boolean);
|
|
344
|
-
return [
|
|
345
|
-
`#${comment.id} [${tags.join(", ")}] ${formatUser(comment.author)}`,
|
|
346
|
-
indent(comment.text),
|
|
347
|
-
""
|
|
348
|
-
];
|
|
349
|
-
})
|
|
350
|
-
].slice(0, -1).join("\n");
|
|
351
|
-
}
|
|
352
|
-
//#endregion
|
|
353
|
-
//#region src/lib/cli-error.ts
|
|
354
|
-
/**
|
|
355
|
-
* Error whose message is meant to be shown to the user verbatim (prefixed with
|
|
356
|
-
* `Error: `) before the CLI exits with a non-zero code. Throw it — via
|
|
357
|
-
* {@link fail} — instead of calling `process.exit` directly so the failure can
|
|
358
|
-
* be unit-tested and handled in a single place ({@link handleCliError}).
|
|
359
|
-
*/
|
|
360
|
-
var CliError = class extends Error {
|
|
361
|
-
constructor(message) {
|
|
362
|
-
super(message);
|
|
363
|
-
this.name = "CliError";
|
|
364
|
-
}
|
|
365
|
-
};
|
|
366
|
-
/**
|
|
367
|
-
* Abort the current command with a user-facing message.
|
|
368
|
-
*/
|
|
369
|
-
function fail(message) {
|
|
370
|
-
throw new CliError(message);
|
|
371
|
-
}
|
|
372
|
-
//#endregion
|
|
373
|
-
//#region src/lib/oauth.ts
|
|
374
|
-
/**
|
|
375
|
-
* OAuth 2.1 client configuration and helpers for the `argos login` flow
|
|
376
|
-
* (Authorization Code + PKCE with a loopback redirect, RFC 8252).
|
|
377
|
-
*
|
|
378
|
-
* The authorization server is the Argos app origin; endpoints and token
|
|
379
|
-
* validation are implemented in the main Argos repo (`apps/backend/src/oauth`).
|
|
380
|
-
*/
|
|
381
|
-
/** First-party public client id, seeded in the Argos database. */
|
|
382
|
-
const OAUTH_CLIENT_ID = "argos-cli";
|
|
383
|
-
/**
|
|
384
|
-
* Scopes requested by `argos login`, covering the CLI's user actions (identity,
|
|
385
|
-
* projects, reviews, comments). Build upload keeps using a project token.
|
|
386
|
-
*/
|
|
387
|
-
const OAUTH_SCOPES = [
|
|
388
|
-
"profile",
|
|
389
|
-
"projects:read",
|
|
390
|
-
"projects:write",
|
|
391
|
-
"reviews:write",
|
|
392
|
-
"comments:read",
|
|
393
|
-
"comments:write"
|
|
394
|
-
];
|
|
395
|
-
/** Refresh a bit early so a token never expires mid-request. */
|
|
396
|
-
const EXPIRY_SKEW_MS = 60 * 1e3;
|
|
397
|
-
function getAppBaseUrl() {
|
|
398
|
-
return process.env["ARGOS_APP_BASE_URL"] ?? "https://app.snapvisor.io/";
|
|
399
|
-
}
|
|
400
|
-
/**
|
|
401
|
-
* A token endpoint request that reached the server and was rejected by it
|
|
402
|
-
* (non-2xx / OAuth error), as opposed to a transient network failure. Lets
|
|
403
|
-
* callers tell "your session is gone, log in again" from "the network is down".
|
|
404
|
-
*/
|
|
405
|
-
var OAuthTokenError = class extends Error {
|
|
406
|
-
code;
|
|
407
|
-
constructor(message, code) {
|
|
408
|
-
super(message);
|
|
409
|
-
this.code = code;
|
|
410
|
-
this.name = "OAuthTokenError";
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
function toTokenSet(data) {
|
|
414
|
-
return {
|
|
415
|
-
accessToken: data.access_token,
|
|
416
|
-
refreshToken: data.refresh_token,
|
|
417
|
-
expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SKEW_MS,
|
|
418
|
-
scope: data.scope ?? ""
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
/** Generate a PKCE `code_verifier` and its S256 `code_challenge`. */
|
|
422
|
-
function generatePkce() {
|
|
423
|
-
const codeVerifier = randomBytes(32).toString("base64url");
|
|
424
|
-
return {
|
|
425
|
-
codeVerifier,
|
|
426
|
-
codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url")
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
/** Build the `/oauth/authorize` URL to open in the browser. */
|
|
430
|
-
function buildAuthorizeUrl(params) {
|
|
431
|
-
const url = new URL("/oauth/authorize", getAppBaseUrl());
|
|
432
|
-
url.searchParams.set("response_type", "code");
|
|
433
|
-
url.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
434
|
-
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
435
|
-
url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
|
|
436
|
-
url.searchParams.set("state", params.state);
|
|
437
|
-
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
438
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
439
|
-
return url.href;
|
|
440
|
-
}
|
|
441
|
-
async function postToken(body) {
|
|
442
|
-
const url = new URL("/oauth/token", getAppBaseUrl());
|
|
443
|
-
const response = await fetch(url, {
|
|
444
|
-
method: "POST",
|
|
445
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
446
|
-
body: new URLSearchParams(body).toString()
|
|
447
|
-
});
|
|
448
|
-
const data = await response.json().catch(() => null);
|
|
449
|
-
if (!response.ok || !data?.access_token) throw new OAuthTokenError(data?.error_description ?? data?.error ?? `HTTP ${response.status}`, data?.error);
|
|
450
|
-
if (typeof data.refresh_token !== "string" || data.refresh_token === "") throw new Error("Token endpoint response is missing a refresh token.");
|
|
451
|
-
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) throw new Error("Token endpoint response is missing a valid expiry.");
|
|
452
|
-
return data;
|
|
122
|
+
async function postToken(body) {
|
|
123
|
+
const url = new URL("/oauth/token", getAppBaseUrl());
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
127
|
+
body: new URLSearchParams(body).toString()
|
|
128
|
+
});
|
|
129
|
+
const data = await response.json().catch(() => null);
|
|
130
|
+
if (!response.ok || !data?.access_token) throw new OAuthTokenError(data?.error_description ?? data?.error ?? `HTTP ${response.status}`, data?.error);
|
|
131
|
+
if (typeof data.refresh_token !== "string" || data.refresh_token === "") throw new Error("Token endpoint response is missing a refresh token.");
|
|
132
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) throw new Error("Token endpoint response is missing a valid expiry.");
|
|
133
|
+
return data;
|
|
453
134
|
}
|
|
454
135
|
/** Exchange an authorization code for a token set (`grant_type=authorization_code`). */
|
|
455
136
|
async function exchangeAuthorizationCode(params) {
|
|
@@ -522,7 +203,7 @@ async function clearConfig() {
|
|
|
522
203
|
await unlink(configPath);
|
|
523
204
|
} catch {}
|
|
524
205
|
}
|
|
525
|
-
async function readConfig() {
|
|
206
|
+
async function readConfig$1() {
|
|
526
207
|
const { configPath } = getConfigPaths();
|
|
527
208
|
try {
|
|
528
209
|
const config = parseConfig(await readFile(configPath, "utf8"));
|
|
@@ -554,7 +235,7 @@ async function saveOAuthTokens(tokens) {
|
|
|
554
235
|
* falls back to a legacy personal access token when present.
|
|
555
236
|
*/
|
|
556
237
|
async function getAccessToken() {
|
|
557
|
-
const config = await readConfig();
|
|
238
|
+
const config = await readConfig$1();
|
|
558
239
|
if (!config) return;
|
|
559
240
|
if (config.oauth) {
|
|
560
241
|
if (Date.now() < config.oauth.expiresAt) return config.oauth.accessToken;
|
|
@@ -564,8 +245,8 @@ async function getAccessToken() {
|
|
|
564
245
|
return tokens.accessToken;
|
|
565
246
|
} catch (err) {
|
|
566
247
|
if (config.token) return config.token;
|
|
567
|
-
if (err instanceof OAuthTokenError) console.warn("Warning: Your
|
|
568
|
-
else console.warn("Warning: Could not reach
|
|
248
|
+
if (err instanceof OAuthTokenError) console.warn("Warning: Your Snapvisor session has expired. Run `argos login` again.");
|
|
249
|
+
else console.warn("Warning: Could not reach Snapvisor to refresh your session. Check your connection and try again.");
|
|
569
250
|
return;
|
|
570
251
|
}
|
|
571
252
|
}
|
|
@@ -573,7 +254,7 @@ async function getAccessToken() {
|
|
|
573
254
|
}
|
|
574
255
|
/** Read the stored credentials without refreshing (used by `logout`). */
|
|
575
256
|
async function getStoredCredentials() {
|
|
576
|
-
const config = await readConfig();
|
|
257
|
+
const config = await readConfig$1();
|
|
577
258
|
return {
|
|
578
259
|
legacyToken: config?.token,
|
|
579
260
|
oauth: config?.oauth
|
|
@@ -585,10 +266,10 @@ async function clearStoredCredentials() {
|
|
|
585
266
|
}
|
|
586
267
|
//#endregion
|
|
587
268
|
//#region src/lib/build-reference.ts
|
|
588
|
-
const BUILD_URL_REGEXP = /^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
|
|
269
|
+
const BUILD_URL_REGEXP = /^https:\/\/app\.(?:argos-ci\.(?:com|dev(?::\d+)?)|snapvisor\.io)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
|
|
589
270
|
/**
|
|
590
271
|
* Parse a build reference, accepting either a bare build number (`"1234"`) or a
|
|
591
|
-
* full
|
|
272
|
+
* full Snapvisor build URL. Returns `null` when the input is neither.
|
|
592
273
|
*/
|
|
593
274
|
function parseBuildReference(reference) {
|
|
594
275
|
const asNumber = Number(reference);
|
|
@@ -607,7 +288,7 @@ function parseBuildReference(reference) {
|
|
|
607
288
|
*/
|
|
608
289
|
function parseBuildReferenceOrFail(reference) {
|
|
609
290
|
const parsed = parseBuildReference(reference);
|
|
610
|
-
if (!parsed) fail(`Build reference must be a valid build number or
|
|
291
|
+
if (!parsed) fail(`Build reference must be a valid build number or Snapvisor build URL (https://app.snapvisor.io/.../builds/<number>), got "${reference}".`);
|
|
611
292
|
return parsed;
|
|
612
293
|
}
|
|
613
294
|
//#endregion
|
|
@@ -640,7 +321,7 @@ function parseProjectPathOrFail(slug) {
|
|
|
640
321
|
*/
|
|
641
322
|
async function resolveToken(options) {
|
|
642
323
|
const token = options.token || process.env["ARGOS_TOKEN"] || await getAccessToken();
|
|
643
|
-
if (!token) fail("No
|
|
324
|
+
if (!token) fail("No Snapvisor token found. Use --token, set ARGOS_TOKEN, or run `argos login`.");
|
|
644
325
|
return token;
|
|
645
326
|
}
|
|
646
327
|
function explicitProjectPath(reference, options) {
|
|
@@ -733,6 +414,526 @@ async function runBuildAction(opts) {
|
|
|
733
414
|
}
|
|
734
415
|
}
|
|
735
416
|
//#endregion
|
|
417
|
+
//#region src/lib/wait-for-build.ts
|
|
418
|
+
/** Commander argument parser for `--wait-timeout <seconds>`. */
|
|
419
|
+
function parseWaitTimeout(value) {
|
|
420
|
+
const seconds = Number(value);
|
|
421
|
+
if (!Number.isInteger(seconds) || seconds <= 0) throw new InvalidArgumentError("Must be a positive whole number of seconds.");
|
|
422
|
+
return seconds;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Reject option combinations `--wait` cannot honour, before anything is uploaded.
|
|
426
|
+
* Returns the token used to poll the build, or `null` when not waiting.
|
|
427
|
+
*/
|
|
428
|
+
async function prepareWait(options) {
|
|
429
|
+
if (!options.wait) {
|
|
430
|
+
if (options.waitTimeout !== void 0) fail("--wait-timeout requires --wait.");
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (options.parallel || (await readConfig()).parallel) fail("--wait cannot be combined with --parallel (or ARGOS_PARALLEL): a parallel build only finishes once every shard is uploaded and the build is finalized, so waiting on one shard would time out.");
|
|
434
|
+
const token = options.token || process.env["ARGOS_TOKEN"];
|
|
435
|
+
if (!token) fail("--wait needs a project token to read the build back: pass --token or set ARGOS_TOKEN (GitHub Actions OIDC / tokenless uploads and `argos login` are not supported with --wait).");
|
|
436
|
+
return token;
|
|
437
|
+
}
|
|
438
|
+
const POLL_INTERVAL_MS = 5e3;
|
|
439
|
+
/** Statuses a build can still leave: it is waiting for, or being processed by, the diff engine. */
|
|
440
|
+
const IN_FLIGHT_STATUSES = ["pending", "progress"];
|
|
441
|
+
function isFinalStatus(status) {
|
|
442
|
+
return !IN_FLIGHT_STATUSES.includes(status);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Map a build that reached a final status to a pass/fail outcome.
|
|
446
|
+
*
|
|
447
|
+
* Passes: `accepted`, `no-changes`, and a `changes-detected` build whose commit
|
|
448
|
+
* status is already `success` (a reference-branch build is auto-approved but
|
|
449
|
+
* keeps the `changes-detected` API status). Everything else fails: a
|
|
450
|
+
* `changes-detected` build with a pending status is waiting for a human review,
|
|
451
|
+
* which is exactly what `--wait` exists to surface to CI, so the exit code
|
|
452
|
+
* always agrees with the commit status.
|
|
453
|
+
*/
|
|
454
|
+
function evaluateFinalBuild(build) {
|
|
455
|
+
if (!isFinalStatus(build.status)) throw new Error(`Invariant: build #${build.number} is still "${build.status}", not final`);
|
|
456
|
+
const status = build.status;
|
|
457
|
+
const label = `Build #${build.number}`;
|
|
458
|
+
switch (status) {
|
|
459
|
+
case "accepted": return {
|
|
460
|
+
ok: true,
|
|
461
|
+
summary: `${label} accepted`
|
|
462
|
+
};
|
|
463
|
+
case "no-changes": return {
|
|
464
|
+
ok: true,
|
|
465
|
+
summary: `${label} has no changes`
|
|
466
|
+
};
|
|
467
|
+
case "changes-detected":
|
|
468
|
+
if (build.notification?.github.state === "success") return {
|
|
469
|
+
ok: true,
|
|
470
|
+
summary: `${label} has changes but its commit status is success (auto-approved)`
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
summary: `${label} needs review (changes-detected)`
|
|
475
|
+
};
|
|
476
|
+
case "rejected": return {
|
|
477
|
+
ok: false,
|
|
478
|
+
summary: `${label} was rejected`
|
|
479
|
+
};
|
|
480
|
+
case "expired": return {
|
|
481
|
+
ok: false,
|
|
482
|
+
summary: `${label} expired`
|
|
483
|
+
};
|
|
484
|
+
case "error": return {
|
|
485
|
+
ok: false,
|
|
486
|
+
summary: `${label} failed with an error`
|
|
487
|
+
};
|
|
488
|
+
case "aborted": return {
|
|
489
|
+
ok: false,
|
|
490
|
+
summary: `${label} was aborted`
|
|
491
|
+
};
|
|
492
|
+
default: throw new Error(`Unhandled build status: ${String(status)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Extract `owner`, `project` and the build number from the build URL returned
|
|
497
|
+
* by the API (`<app origin>/<owner>/<project>/builds/<number>`). Only the last
|
|
498
|
+
* four path segments are read, so self-hosted origins work too.
|
|
499
|
+
*/
|
|
500
|
+
function parseBuildUrl(url) {
|
|
501
|
+
const [owner, project, builds, buildNumber] = new URL(url).pathname.split("/").filter(Boolean).slice(-4).map((segment) => {
|
|
502
|
+
try {
|
|
503
|
+
return decodeURIComponent(segment);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
fail(`Cannot wait for build: malformed build URL "${url}" (${error instanceof Error ? error.message : String(error)}).`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
if (!owner || !project || builds !== "builds" || !buildNumber) fail(`Cannot wait for build: unexpected build URL "${url}" (expected .../<owner>/<project>/builds/<number>).`);
|
|
509
|
+
return {
|
|
510
|
+
owner,
|
|
511
|
+
project,
|
|
512
|
+
buildNumber
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const realClock = {
|
|
516
|
+
now: () => Date.now(),
|
|
517
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
518
|
+
};
|
|
519
|
+
const MAX_RATE_LIMIT_BACKOFF_MS = 6e4;
|
|
520
|
+
/**
|
|
521
|
+
* How long to wait before retrying a 429: the `Retry-After` header (seconds or
|
|
522
|
+
* HTTP date) when present and valid, else exponential backoff from the poll interval.
|
|
523
|
+
*/
|
|
524
|
+
function rateLimitDelayMs(response, attempt, pollIntervalMs, nowMs) {
|
|
525
|
+
const header = response.headers.get("retry-after");
|
|
526
|
+
if (header) {
|
|
527
|
+
const seconds = Number(header);
|
|
528
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
529
|
+
const date = Date.parse(header);
|
|
530
|
+
if (!Number.isNaN(date)) return Math.max(0, date - nowMs);
|
|
531
|
+
}
|
|
532
|
+
return Math.min(MAX_RATE_LIMIT_BACKOFF_MS, pollIntervalMs * 2 ** attempt);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Poll a build until it reaches a final status and return its pass/fail
|
|
536
|
+
* outcome. Throws a {@link CliError} when `timeoutSeconds` elapses first, and
|
|
537
|
+
* lets API failures (401, 404, network errors, ...) propagate — a build we
|
|
538
|
+
* cannot read must not look green. A 429 is retried (honouring `Retry-After`)
|
|
539
|
+
* for as long as the deadline allows. Each request is aborted at the deadline,
|
|
540
|
+
* so the wait never overshoots `timeoutSeconds` by a whole request.
|
|
541
|
+
*/
|
|
542
|
+
async function waitForBuild(opts) {
|
|
543
|
+
const { client, buildUrl, timeoutSeconds, pollIntervalMs = POLL_INTERVAL_MS, clock = realClock, onPoll } = opts;
|
|
544
|
+
const { owner, project, buildNumber } = parseBuildUrl(buildUrl);
|
|
545
|
+
const deadline = clock.now() + timeoutSeconds * 1e3;
|
|
546
|
+
let lastStatus = "not yet read";
|
|
547
|
+
let rateLimitAttempt = 0;
|
|
548
|
+
const getBuild = (signal) => client.GET("/projects/{owner}/{project}/builds/{buildNumber}", {
|
|
549
|
+
params: { path: {
|
|
550
|
+
owner,
|
|
551
|
+
project,
|
|
552
|
+
buildNumber
|
|
553
|
+
} },
|
|
554
|
+
signal
|
|
555
|
+
});
|
|
556
|
+
const timeout = () => fail(`Timed out after ${timeoutSeconds}s waiting for build #${buildNumber} to finish (last status: ${lastStatus}). ${buildUrl}`);
|
|
557
|
+
for (;;) {
|
|
558
|
+
const remainingMs = deadline - clock.now();
|
|
559
|
+
if (remainingMs <= 0) timeout();
|
|
560
|
+
const signal = AbortSignal.timeout(remainingMs);
|
|
561
|
+
let result;
|
|
562
|
+
try {
|
|
563
|
+
result = await getBuild(signal);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (signal.aborted) timeout();
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
if (result.response.status === 429) {
|
|
569
|
+
const delayMs = rateLimitDelayMs(result.response, rateLimitAttempt++, pollIntervalMs, clock.now());
|
|
570
|
+
lastStatus = "rate limited by the API (HTTP 429)";
|
|
571
|
+
if (delayMs >= deadline - clock.now()) timeout();
|
|
572
|
+
await clock.sleep(delayMs);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
rateLimitAttempt = 0;
|
|
576
|
+
const build = unwrap(result);
|
|
577
|
+
lastStatus = build.status;
|
|
578
|
+
if (isFinalStatus(build.status)) return evaluateFinalBuild(build);
|
|
579
|
+
onPoll?.(build);
|
|
580
|
+
const untilDeadlineMs = deadline - clock.now();
|
|
581
|
+
if (untilDeadlineMs <= 0) timeout();
|
|
582
|
+
await clock.sleep(Math.min(pollIntervalMs, untilDeadlineMs));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/options.ts
|
|
587
|
+
const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build");
|
|
588
|
+
const tokenOption = new Option("--token <token>", "Repository token");
|
|
589
|
+
const projectOption = new Option("--project <slug>", "Snapvisor project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository");
|
|
590
|
+
const buildNameOption = new Option("--build-name <string>", "Name of the build, in case you want to run multiple Snapvisor builds in a single CI job");
|
|
591
|
+
const accountOption = new Option("--account <slug>", "Slug of the account (personal or team) that will own the project");
|
|
592
|
+
const jsonOption = new Option("--json", "Output machine-readable JSON instead of human-readable text");
|
|
593
|
+
const projectPathOption = new Option("--project <owner/project>", "Project path in owner/project format. Required for build-number references on review and comment commands");
|
|
594
|
+
const changeProjectPathOption = new Option("--project <owner/project>", "Project the change belongs to, in owner/project format. Also ARGOS_PROJECT");
|
|
595
|
+
const METRICS_PERIOD_BY_CHOICE = {
|
|
596
|
+
"24h": "LAST_24_HOURS",
|
|
597
|
+
"3d": "LAST_3_DAYS",
|
|
598
|
+
"7d": "LAST_7_DAYS",
|
|
599
|
+
"30d": "LAST_30_DAYS",
|
|
600
|
+
"90d": "LAST_90_DAYS"
|
|
601
|
+
};
|
|
602
|
+
const metricsPeriodOption = new Option("--metrics-period <period>", "Window over which test flakiness metrics are computed").choices(Object.keys(METRICS_PERIOD_BY_CHOICE)).default("7d");
|
|
603
|
+
/** Map a `--metrics-period` choice to the value the API expects. */
|
|
604
|
+
function toMetricsPeriod(choice) {
|
|
605
|
+
return METRICS_PERIOD_BY_CHOICE[choice] ?? "LAST_7_DAYS";
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/commands/upload.ts
|
|
609
|
+
function uploadCommand(program) {
|
|
610
|
+
program.command("upload").argument("<directory>", "Directory to upload").description("Upload screenshots to Snapvisor").option("-f, --files <patterns...>", "One or more globs matching image file paths to upload", "**/*.{png,jpg,jpeg}").option("-i, --ignore <patterns...>", "One or more globs matching image file paths to ignore (ex: \"**/*.png **/diff.jpg\")").addOption(tokenOption).addOption(projectOption).addOption(buildNameOption).addOption(new Option("--mode <string>", "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.").default("ci").choices(["ci", "monitoring"])).addOption(new Option("--parallel", "Enable parallel mode. Run multiple Snapvisor builds and combine them at the end.\nCan also be set via the ARGOS_PARALLEL environment variable.")).addOption(new Option("--parallel-total <number>", "The number of parallel nodes being ran")).addOption(parallelNonceOption).addOption(new Option("--parallel-index <number>", "The index of the parallel node being ran (must be at least 1)")).addOption(new Option("--reference-branch <string>", "Branch used as baseline for screenshot comparison")).addOption(new Option("--reference-commit <string>", "Commit used as baseline for screenshot comparison")).addOption(new Option("--threshold <number>", "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5")).addOption(new Option("--subset", "Whether this build contains only a subset of screenshots.\nThis is useful when a build is created from an incomplete test suite where some tests are skipped.\nCan also be set via the ARGOS_SUBSET environment variable.")).addOption(new Option("--wait", "Wait for the build to finish, then exit 0 if it is accepted or has no changes, and exit 1 if it needs review, is rejected, expired, aborted or errored, or if the wait times out.\nRequires a token (--token, ARGOS_TOKEN) and cannot be combined with --parallel.")).addOption(new Option("--wait-timeout <seconds>", `Maximum time to wait for the build when --wait is set. Defaults to 600.`).argParser(parseWaitTimeout)).action(async (directory, options) => {
|
|
611
|
+
let waitToken;
|
|
612
|
+
try {
|
|
613
|
+
waitToken = await prepareWait({
|
|
614
|
+
wait: options.wait,
|
|
615
|
+
waitTimeout: options.waitTimeout,
|
|
616
|
+
parallel: options.parallel,
|
|
617
|
+
token: options.token
|
|
618
|
+
});
|
|
619
|
+
} catch (error) {
|
|
620
|
+
handleCliError(error);
|
|
621
|
+
}
|
|
622
|
+
const spinner = ora("Uploading screenshots").start();
|
|
623
|
+
let buildUrl;
|
|
624
|
+
try {
|
|
625
|
+
const parallel = (() => {
|
|
626
|
+
if (!options.parallel) return;
|
|
627
|
+
if (!options.parallelNonce) {
|
|
628
|
+
spinner.fail("--parallel-nonce is required if --parallel is set");
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
if (!options.parallelTotal) {
|
|
632
|
+
spinner.fail("--parallel-total is required if --parallel is set");
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
nonce: options.parallelNonce,
|
|
637
|
+
total: options.parallelTotal,
|
|
638
|
+
index: options.parallelIndex
|
|
639
|
+
};
|
|
640
|
+
})();
|
|
641
|
+
buildUrl = (await upload({
|
|
642
|
+
token: options.token,
|
|
643
|
+
project: options.project,
|
|
644
|
+
root: directory,
|
|
645
|
+
buildName: options.buildName,
|
|
646
|
+
files: options.files,
|
|
647
|
+
ignore: options.ignore,
|
|
648
|
+
parallel,
|
|
649
|
+
referenceBranch: options.referenceBranch,
|
|
650
|
+
referenceCommit: options.referenceCommit,
|
|
651
|
+
mode: options.mode,
|
|
652
|
+
threshold: options.threshold,
|
|
653
|
+
subset: options.subset
|
|
654
|
+
})).build.url;
|
|
655
|
+
spinner.succeed(`Build created: ${buildUrl}`);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (error instanceof Error) {
|
|
658
|
+
spinner.fail(`Build failed: ${error.message}`);
|
|
659
|
+
console.error(error.stack);
|
|
660
|
+
}
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
if (waitToken === null) return;
|
|
664
|
+
const waitSpinner = ora("Waiting for the build to finish").start();
|
|
665
|
+
try {
|
|
666
|
+
const outcome = await waitForBuild({
|
|
667
|
+
client: createApiClient(waitToken),
|
|
668
|
+
buildUrl,
|
|
669
|
+
timeoutSeconds: options.waitTimeout ?? 600,
|
|
670
|
+
onPoll: (build) => {
|
|
671
|
+
waitSpinner.text = `Waiting for the build to finish (status: ${build.status})`;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
if (outcome.ok) waitSpinner.succeed(`${outcome.summary}: ${buildUrl}`);
|
|
675
|
+
else {
|
|
676
|
+
waitSpinner.fail(`${outcome.summary}: ${buildUrl}`);
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
} catch (error) {
|
|
680
|
+
waitSpinner.fail("Waiting for the build failed");
|
|
681
|
+
handleCliError(error);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/commands/finalize.ts
|
|
687
|
+
const skipIfEmptyOption = new Option("--skip-if-empty", "Create a skipped build when no parallel build matches the nonce.\nUseful when uploads are conditional (e.g. skipped by a CI cache) and Snapvisor is a required status check: the skipped build reports a success status even though nothing was uploaded.");
|
|
688
|
+
function finalizeCommand(program) {
|
|
689
|
+
program.command("finalize").description("Finalize pending parallel builds").addOption(parallelNonceOption).addOption(tokenOption).addOption(skipIfEmptyOption).addOption(buildNameOption).action(async (options) => {
|
|
690
|
+
const spinner = ora("Finalizing builds").start();
|
|
691
|
+
try {
|
|
692
|
+
const result = await finalize({
|
|
693
|
+
token: options.token,
|
|
694
|
+
parallel: options.parallelNonce ? { nonce: options.parallelNonce } : void 0,
|
|
695
|
+
skipIfEmpty: options.skipIfEmpty,
|
|
696
|
+
buildName: options.buildName
|
|
697
|
+
});
|
|
698
|
+
spinner.succeed(result.skippedBuild ? `No builds to finalize — skipped build created: ${result.skippedBuild.url}` : result.builds.length === 0 ? "No builds to finalize" : `Builds finalized: ${result.builds.map((b) => b.url).join(", ")}`);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof Error) {
|
|
701
|
+
spinner.fail(`Failed to finalize: ${error.message}`);
|
|
702
|
+
console.error(error.stack);
|
|
703
|
+
}
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/commands/skip.ts
|
|
710
|
+
function skipCommand(program) {
|
|
711
|
+
program.command("skip").description("Mark a build as skipped").addOption(tokenOption).addOption(buildNameOption).action(async (options) => {
|
|
712
|
+
const spinner = ora("Creating skipped build").start();
|
|
713
|
+
try {
|
|
714
|
+
const result = await skip({
|
|
715
|
+
token: options.token,
|
|
716
|
+
buildName: options.buildName
|
|
717
|
+
});
|
|
718
|
+
spinner.succeed(`Build created: ${result.build.url}`);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error instanceof Error) {
|
|
721
|
+
spinner.fail(`Build failed: ${error.message}`);
|
|
722
|
+
console.error(error.stack);
|
|
723
|
+
}
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/lib/format.ts
|
|
730
|
+
/** Render a scalar, using `-` for empty values. */
|
|
731
|
+
function formatValue(value) {
|
|
732
|
+
if (value === null || value === void 0 || value === "") return "-";
|
|
733
|
+
return String(value);
|
|
734
|
+
}
|
|
735
|
+
function formatUser(user) {
|
|
736
|
+
if (!user) return "-";
|
|
737
|
+
return user.name ? `${user.name} (@${user.slug})` : `@${user.slug}`;
|
|
738
|
+
}
|
|
739
|
+
function formatMe(me) {
|
|
740
|
+
const lines = [
|
|
741
|
+
`Logged in to Snapvisor as ${formatValue(me.user.name)}.`,
|
|
742
|
+
`Name: ${formatValue(me.user.name)}`,
|
|
743
|
+
`Email: ${formatValue(me.user.email)}`
|
|
744
|
+
];
|
|
745
|
+
if (me.accounts.length > 0) lines.push(`Accounts: ${me.accounts.map((a) => `@${a.slug}`).join(", ")}`);
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
function formatProject(project) {
|
|
749
|
+
return [
|
|
750
|
+
`Created project ${project.account.slug}/${project.name}.`,
|
|
751
|
+
`ID: ${project.id}`,
|
|
752
|
+
`Name: ${project.name}`,
|
|
753
|
+
`Account: ${project.account.slug}`,
|
|
754
|
+
`Default base branch: ${formatValue(project.defaultBaseBranch)}`
|
|
755
|
+
].join("\n");
|
|
756
|
+
}
|
|
757
|
+
function formatStats(stats) {
|
|
758
|
+
if (!stats) return "-";
|
|
759
|
+
return [
|
|
760
|
+
`total ${stats.total}`,
|
|
761
|
+
`changed ${stats.changed}`,
|
|
762
|
+
`added ${stats.added}`,
|
|
763
|
+
`removed ${stats.removed}`,
|
|
764
|
+
`unchanged ${stats.unchanged}`
|
|
765
|
+
].join(", ");
|
|
766
|
+
}
|
|
767
|
+
const SNAPSHOT_STATUS_ORDER = [
|
|
768
|
+
"changed",
|
|
769
|
+
"added",
|
|
770
|
+
"removed",
|
|
771
|
+
"unchanged",
|
|
772
|
+
"ignored",
|
|
773
|
+
"pending",
|
|
774
|
+
"failure",
|
|
775
|
+
"retryFailure"
|
|
776
|
+
];
|
|
777
|
+
function formatSnapshotSummary(diffs) {
|
|
778
|
+
const counts = /* @__PURE__ */ new Map();
|
|
779
|
+
for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
|
|
780
|
+
return SNAPSHOT_STATUS_ORDER.map((status) => {
|
|
781
|
+
const count = counts.get(status);
|
|
782
|
+
return count ? `${status} ${count}` : null;
|
|
783
|
+
}).filter((part) => Boolean(part)).join(", ");
|
|
784
|
+
}
|
|
785
|
+
function formatBuild(build) {
|
|
786
|
+
return [
|
|
787
|
+
`Build #${build.number}`,
|
|
788
|
+
`Status: ${build.status}`,
|
|
789
|
+
`Snapshots: ${formatStats(build.stats)}`,
|
|
790
|
+
`Conclusion: ${formatValue(build.conclusion)}`,
|
|
791
|
+
`Branch: ${formatValue(build.head?.branch)}`,
|
|
792
|
+
`Commit: ${formatValue(build.head?.sha)}`,
|
|
793
|
+
`Base branch: ${formatValue(build.base?.branch)}`,
|
|
794
|
+
`Base commit: ${formatValue(build.base?.sha)}`,
|
|
795
|
+
`URL: ${build.url}`
|
|
796
|
+
].join("\n");
|
|
797
|
+
}
|
|
798
|
+
/** Render a 0–1 metric to two decimals, or `-` when absent. */
|
|
799
|
+
function formatRatio(value) {
|
|
800
|
+
return value === null || value === void 0 ? "-" : value.toFixed(2);
|
|
801
|
+
}
|
|
802
|
+
/** Compact one-line summary of a test's flakiness metrics. */
|
|
803
|
+
function formatFlakiness(metrics) {
|
|
804
|
+
return `${formatRatio(metrics.flakiness)} (stability ${formatRatio(metrics.stability)}, consistency ${formatRatio(metrics.consistency)})`;
|
|
805
|
+
}
|
|
806
|
+
/** Human-readable lines for a single snapshot diff. */
|
|
807
|
+
function formatSnapshotDiff(diff, build) {
|
|
808
|
+
const lines = [
|
|
809
|
+
`${diff.name} [${diff.status}]`,
|
|
810
|
+
` Review: ${build.url}/${diff.id}`,
|
|
811
|
+
` Mask: ${formatValue(diff.url)}`,
|
|
812
|
+
` Base file: ${formatValue(diff.base?.url)}`,
|
|
813
|
+
` Head file: ${formatValue(diff.head?.url)}`,
|
|
814
|
+
` Score: ${formatValue(diff.score)}`,
|
|
815
|
+
` Group: ${formatValue(diff.group)}`
|
|
816
|
+
];
|
|
817
|
+
if (diff.test) lines.push(` Flakiness: ${formatFlakiness(diff.test.metrics)}`);
|
|
818
|
+
if (diff.change) {
|
|
819
|
+
const ignored = diff.change.ignored ? " [ignored]" : "";
|
|
820
|
+
lines.push(` Change: ${diff.change.id}${ignored} · ${diff.change.occurrences} occurrences`);
|
|
821
|
+
}
|
|
822
|
+
return lines;
|
|
823
|
+
}
|
|
824
|
+
function formatSnapshots(diffs, build) {
|
|
825
|
+
if (diffs.length === 0) return "No snapshots found.";
|
|
826
|
+
return [
|
|
827
|
+
`Snapshots for build #${build.number}`,
|
|
828
|
+
`Count: ${diffs.length}`,
|
|
829
|
+
`Summary: ${formatSnapshotSummary(diffs)}`,
|
|
830
|
+
"",
|
|
831
|
+
...diffs.flatMap((diff) => [...formatSnapshotDiff(diff, build), ""])
|
|
832
|
+
].slice(0, -1).join("\n");
|
|
833
|
+
}
|
|
834
|
+
function formatChange(change) {
|
|
835
|
+
return [
|
|
836
|
+
`Change ${change.id}`,
|
|
837
|
+
`Ignored: ${change.ignored ? "yes" : "no"}`,
|
|
838
|
+
`Occurrences: ${change.occurrences}`
|
|
839
|
+
].join("\n");
|
|
840
|
+
}
|
|
841
|
+
function formatReview(review) {
|
|
842
|
+
const lines = [
|
|
843
|
+
`Review #${review.id}`,
|
|
844
|
+
`State: ${review.state}`,
|
|
845
|
+
`Author: ${formatUser(review.user)}`,
|
|
846
|
+
`Date: ${review.date}`
|
|
847
|
+
];
|
|
848
|
+
if (review.dismissedAt) lines.push(`Dismissed: ${review.dismissedAt} by ${formatUser(review.dismissedBy)}`);
|
|
849
|
+
return lines.join("\n");
|
|
850
|
+
}
|
|
851
|
+
function formatReviews(reviews) {
|
|
852
|
+
if (reviews.length === 0) return "No reviews found.";
|
|
853
|
+
return [
|
|
854
|
+
`Reviews (${reviews.length})`,
|
|
855
|
+
"",
|
|
856
|
+
...reviews.flatMap((review) => [
|
|
857
|
+
`#${review.id} [${review.state}${review.dismissedAt ? ", dismissed" : ""}]`,
|
|
858
|
+
` Author: ${formatUser(review.user)}`,
|
|
859
|
+
` Date: ${review.date}`,
|
|
860
|
+
""
|
|
861
|
+
])
|
|
862
|
+
].slice(0, -1).join("\n");
|
|
863
|
+
}
|
|
864
|
+
function formatAnalytics(analytics, context) {
|
|
865
|
+
const { screenshots, builds } = analytics;
|
|
866
|
+
const lines = [
|
|
867
|
+
`Analytics for ${context.account}`,
|
|
868
|
+
`Period: ${context.from} → ${context.to ?? "now"} (grouped by ${context.groupBy})`,
|
|
869
|
+
"",
|
|
870
|
+
`Builds: ${builds.all.total}`,
|
|
871
|
+
` Changes detected: ${builds.all.changesDetected}`,
|
|
872
|
+
` No changes: ${builds.all.noChanges}`,
|
|
873
|
+
` Accepted: ${builds.all.accepted}`,
|
|
874
|
+
` Rejected: ${builds.all.rejected}`,
|
|
875
|
+
`Screenshots: ${screenshots.all.total}`
|
|
876
|
+
];
|
|
877
|
+
const names = /* @__PURE__ */ new Map();
|
|
878
|
+
for (const project of [...builds.projects, ...screenshots.projects]) names.set(project.id, project.name);
|
|
879
|
+
const ids = /* @__PURE__ */ new Set([...Object.keys(builds.all.projects), ...Object.keys(screenshots.all.projects)]);
|
|
880
|
+
if (ids.size > 0) {
|
|
881
|
+
const rows = [...ids].map((id) => ({
|
|
882
|
+
name: names.get(id) ?? id,
|
|
883
|
+
builds: builds.all.projects[id] ?? 0,
|
|
884
|
+
screenshots: screenshots.all.projects[id] ?? 0
|
|
885
|
+
})).sort((a, b) => b.builds - a.builds || b.screenshots - a.screenshots);
|
|
886
|
+
lines.push("", `Projects (${rows.length}):`);
|
|
887
|
+
for (const row of rows) lines.push(` ${row.name}: ${row.builds} builds, ${row.screenshots} screenshots`);
|
|
888
|
+
}
|
|
889
|
+
return lines.join("\n");
|
|
890
|
+
}
|
|
891
|
+
function indent(text) {
|
|
892
|
+
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
893
|
+
}
|
|
894
|
+
function formatAnchor(anchor) {
|
|
895
|
+
if (!anchor) return null;
|
|
896
|
+
return anchor.type === "point" ? `point ${anchor.x},${anchor.y}` : `lines ${anchor.from}-${anchor.to}`;
|
|
897
|
+
}
|
|
898
|
+
function formatReactions(reactions) {
|
|
899
|
+
if (reactions.length === 0) return null;
|
|
900
|
+
return reactions.map((r) => `${r.emoji} ${r.count}`).join(" ");
|
|
901
|
+
}
|
|
902
|
+
function formatComment(comment) {
|
|
903
|
+
const lines = [`Comment #${comment.id}`, `Author: ${formatUser(comment.author)}`];
|
|
904
|
+
if (comment.threadId) lines.push(`Reply to: ${comment.threadId}`);
|
|
905
|
+
if (comment.screenshotDiffId) {
|
|
906
|
+
const anchor = formatAnchor(comment.anchor);
|
|
907
|
+
lines.push(`Diff: ${comment.screenshotDiffId}${anchor ? ` (${anchor})` : ""}`);
|
|
908
|
+
}
|
|
909
|
+
if (comment.pending) lines.push("Pending: draft (only visible to you)");
|
|
910
|
+
if (comment.resolvedAt) lines.push(`Resolved: ${comment.resolvedAt}`);
|
|
911
|
+
const reactions = formatReactions(comment.reactions);
|
|
912
|
+
if (reactions) lines.push(`Reactions: ${reactions}`);
|
|
913
|
+
lines.push(`Date: ${comment.createdAt}${comment.editedAt ? " (edited)" : ""}`);
|
|
914
|
+
lines.push("", indent(comment.text));
|
|
915
|
+
return lines.join("\n");
|
|
916
|
+
}
|
|
917
|
+
function formatComments(comments) {
|
|
918
|
+
if (comments.length === 0) return "No comments found.";
|
|
919
|
+
return [
|
|
920
|
+
`Comments (${comments.length})`,
|
|
921
|
+
"",
|
|
922
|
+
...comments.flatMap((comment) => {
|
|
923
|
+
const tags = [
|
|
924
|
+
comment.threadId ? "reply" : "thread",
|
|
925
|
+
comment.pending ? "draft" : null,
|
|
926
|
+
comment.resolvedAt ? "resolved" : null
|
|
927
|
+
].filter(Boolean);
|
|
928
|
+
return [
|
|
929
|
+
`#${comment.id} [${tags.join(", ")}] ${formatUser(comment.author)}`,
|
|
930
|
+
indent(comment.text),
|
|
931
|
+
""
|
|
932
|
+
];
|
|
933
|
+
})
|
|
934
|
+
].slice(0, -1).join("\n");
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
736
937
|
//#region src/commands/build/get.ts
|
|
737
938
|
function registerBuildGet(build) {
|
|
738
939
|
build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
|
@@ -820,7 +1021,7 @@ function buildCommand(program) {
|
|
|
820
1021
|
* Resolve the Markdown body of a comment or review from the mutually-exclusive
|
|
821
1022
|
* `--body` (inline) and `--body-file` (path) options.
|
|
822
1023
|
*
|
|
823
|
-
* The
|
|
1024
|
+
* The Snapvisor API also accepts a rich-text JSON document, but that form is only
|
|
824
1025
|
* needed for `@mentions` (which require user ids); the CLI exposes Markdown
|
|
825
1026
|
* only, which the API converts server-side.
|
|
826
1027
|
*/
|
|
@@ -843,7 +1044,7 @@ const events = {
|
|
|
843
1044
|
comment: "COMMENT"
|
|
844
1045
|
};
|
|
845
1046
|
function registerReviewCreate(review) {
|
|
846
|
-
review.command("create").description("Submit a review on a build").argument("<buildReference>", "Build number or
|
|
1047
|
+
review.command("create").description("Submit a review on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(new Option("--event <event>", "Review event to apply to the build").choices(Object.keys(events)).makeOptionMandatory()).option("--body <markdown>", "Markdown comment to attach to the review").option("--body-file <path>", "Read the review comment from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
|
847
1048
|
reference,
|
|
848
1049
|
options,
|
|
849
1050
|
auth: "user",
|
|
@@ -867,7 +1068,7 @@ function registerReviewCreate(review) {
|
|
|
867
1068
|
//#endregion
|
|
868
1069
|
//#region src/commands/review/dismiss.ts
|
|
869
1070
|
function registerReviewDismiss(review) {
|
|
870
|
-
review.command("dismiss").description("Dismiss a submitted review on a build").argument("<buildReference>", "Build number or
|
|
1071
|
+
review.command("dismiss").description("Dismiss a submitted review on a build").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<reviewId>", "ID of the review to dismiss").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, reviewId, options) => runBuildAction({
|
|
871
1072
|
reference,
|
|
872
1073
|
options,
|
|
873
1074
|
auth: "user",
|
|
@@ -883,7 +1084,7 @@ function registerReviewDismiss(review) {
|
|
|
883
1084
|
//#endregion
|
|
884
1085
|
//#region src/commands/review/list.ts
|
|
885
1086
|
function registerReviewList(review) {
|
|
886
|
-
review.command("list").description("List the reviews submitted on a build").argument("<buildReference>", "Build number or
|
|
1087
|
+
review.command("list").description("List the reviews submitted on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
|
887
1088
|
reference,
|
|
888
1089
|
options,
|
|
889
1090
|
auth: "user",
|
|
@@ -943,7 +1144,7 @@ function parseAnchor(options) {
|
|
|
943
1144
|
//#endregion
|
|
944
1145
|
//#region src/commands/comment/create.ts
|
|
945
1146
|
function registerCommentCreate(comment) {
|
|
946
|
-
comment.command("create").description("Post a comment (or reply) on a build").argument("<buildReference>", "Build number or
|
|
1147
|
+
comment.command("create").description("Post a comment (or reply) on a build").argument("<buildReference>", "Build number or Snapvisor build URL").option("--body <markdown>", "Markdown body of the comment").option("--body-file <path>", "Read the comment body from a Markdown file").option("--reply-to <threadId>", "Reply to an existing thread (its root comment ID)").option("--diff <screenshotDiffId>", "Anchor the comment to a screenshot diff").option("--anchor-point <x,y>", "Point on the diff in normalized 0-1 coordinates (requires --diff)").option("--anchor-lines <from,to>", "1-based inclusive line range on the diff (requires --diff)").option("--draft", "Attach to your pending review instead of posting immediately").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
|
947
1148
|
reference,
|
|
948
1149
|
options,
|
|
949
1150
|
auth: "user",
|
|
@@ -978,7 +1179,7 @@ function registerCommentCreate(comment) {
|
|
|
978
1179
|
*/
|
|
979
1180
|
function defineCommentAction(opts) {
|
|
980
1181
|
return (comment) => {
|
|
981
|
-
comment.command(opts.name).description(opts.description).argument("<buildReference>", "Build number or
|
|
1182
|
+
comment.command(opts.name).description(opts.description).argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
|
|
982
1183
|
reference,
|
|
983
1184
|
options,
|
|
984
1185
|
auth: "user",
|
|
@@ -1008,7 +1209,7 @@ const registerCommentDelete = defineCommentAction({
|
|
|
1008
1209
|
//#endregion
|
|
1009
1210
|
//#region src/commands/comment/edit.ts
|
|
1010
1211
|
function registerCommentEdit(comment) {
|
|
1011
|
-
comment.command("edit").description("Update the body of a comment (author only)").argument("<buildReference>", "Build number or
|
|
1212
|
+
comment.command("edit").description("Update the body of a comment (author only)").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").option("--body <markdown>", "New Markdown body of the comment").option("--body-file <path>", "Read the new body from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
|
|
1012
1213
|
reference,
|
|
1013
1214
|
options,
|
|
1014
1215
|
auth: "user",
|
|
@@ -1042,7 +1243,7 @@ const registerCommentGet = defineCommentAction({
|
|
|
1042
1243
|
//#endregion
|
|
1043
1244
|
//#region src/commands/comment/list.ts
|
|
1044
1245
|
function registerCommentList(comment) {
|
|
1045
|
-
comment.command("list").description("List the comments on a build").argument("<buildReference>", "Build number or
|
|
1246
|
+
comment.command("list").description("List the comments on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
|
1046
1247
|
reference,
|
|
1047
1248
|
options,
|
|
1048
1249
|
auth: "user",
|
|
@@ -1057,7 +1258,7 @@ function registerCommentList(comment) {
|
|
|
1057
1258
|
//#endregion
|
|
1058
1259
|
//#region src/commands/comment/react.ts
|
|
1059
1260
|
function registerCommentReact(comment) {
|
|
1060
|
-
comment.command("react").description("Add an emoji reaction to a comment").argument("<buildReference>", "Build number or
|
|
1261
|
+
comment.command("react").description("Add an emoji reaction to a comment").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji to react with").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
|
|
1061
1262
|
reference,
|
|
1062
1263
|
options,
|
|
1063
1264
|
auth: "user",
|
|
@@ -1100,7 +1301,7 @@ const registerCommentSubscribe = defineCommentAction({
|
|
|
1100
1301
|
//#endregion
|
|
1101
1302
|
//#region src/commands/comment/unreact.ts
|
|
1102
1303
|
function registerCommentUnreact(comment) {
|
|
1103
|
-
comment.command("unreact").description("Remove one of your emoji reactions from a comment").argument("<buildReference>", "Build number or
|
|
1304
|
+
comment.command("unreact").description("Remove one of your emoji reactions from a comment").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji reaction to remove").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
|
|
1104
1305
|
reference,
|
|
1105
1306
|
options,
|
|
1106
1307
|
auth: "user",
|
|
@@ -1163,7 +1364,7 @@ const LOGIN_TIMEOUT_MS = 300 * 1e3;
|
|
|
1163
1364
|
const CALLBACK_ERROR_HTML = `<!DOCTYPE html><html lang="en">
|
|
1164
1365
|
<head>
|
|
1165
1366
|
<meta charset="UTF-8">
|
|
1166
|
-
<title>
|
|
1367
|
+
<title>Snapvisor CLI</title>
|
|
1167
1368
|
</head>
|
|
1168
1369
|
<body>
|
|
1169
1370
|
<p>Authorization failed. Please run <code>argos login</code> again.</p>
|
|
@@ -1297,7 +1498,7 @@ function loginCommand(program) {
|
|
|
1297
1498
|
console.error(errorColor(`Error: Authentication failed${err instanceof Error ? ` — ${err.message}` : ""}. Please run \`argos login\` again.`));
|
|
1298
1499
|
process.exit(1);
|
|
1299
1500
|
}
|
|
1300
|
-
console.log(successColor("Logged in to
|
|
1501
|
+
console.log(successColor("Logged in to Snapvisor successfully."));
|
|
1301
1502
|
});
|
|
1302
1503
|
}
|
|
1303
1504
|
//#endregion
|
|
@@ -1311,7 +1512,7 @@ function logoutCommand(program) {
|
|
|
1311
1512
|
}
|
|
1312
1513
|
if (oauth) await revokeToken(oauth.refreshToken);
|
|
1313
1514
|
await clearStoredCredentials();
|
|
1314
|
-
console.log("Logged out from
|
|
1515
|
+
console.log("Logged out from Snapvisor.");
|
|
1315
1516
|
});
|
|
1316
1517
|
}
|
|
1317
1518
|
//#endregion
|