git-shots-cli 0.1.1 → 0.2.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/dist/index.js +251 -0
- package/package.json +1 -1
- package/src/hook.ts +124 -0
- package/src/index.ts +51 -0
- package/src/review.ts +199 -0
package/dist/index.js
CHANGED
|
@@ -216,6 +216,233 @@ async function pullBaselines(config, options) {
|
|
|
216
216
|
console.log(chalk4.green(`Downloaded ${downloaded} baselines to ${outputDir}`));
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// src/review.ts
|
|
220
|
+
import { execSync as execSync2 } from "child_process";
|
|
221
|
+
import { platform } from "os";
|
|
222
|
+
import chalk5 from "chalk";
|
|
223
|
+
function openBrowser(url) {
|
|
224
|
+
try {
|
|
225
|
+
const os = platform();
|
|
226
|
+
if (os === "win32") {
|
|
227
|
+
execSync2(`start "" "${url}"`, { stdio: "ignore" });
|
|
228
|
+
} else if (os === "darwin") {
|
|
229
|
+
execSync2(`open "${url}"`, { stdio: "ignore" });
|
|
230
|
+
} else {
|
|
231
|
+
execSync2(`xdg-open "${url}"`, { stdio: "ignore" });
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
console.log(chalk5.dim(`Could not open browser. Visit the URL manually.`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function sleep(ms) {
|
|
238
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
239
|
+
}
|
|
240
|
+
async function review(config, options) {
|
|
241
|
+
const shouldOpen = options.open ?? true;
|
|
242
|
+
const shouldPoll = options.poll ?? true;
|
|
243
|
+
const timeoutSec = options.timeout ?? 300;
|
|
244
|
+
const branch = options.branch ?? execSync2("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
|
|
245
|
+
const sha = options.sha ?? execSync2("git rev-parse HEAD", { encoding: "utf-8" }).trim();
|
|
246
|
+
console.log(chalk5.dim(`Project: ${config.project}`));
|
|
247
|
+
console.log(chalk5.dim(`Branch: ${branch}`));
|
|
248
|
+
console.log(chalk5.dim(`SHA: ${sha.slice(0, 7)}`));
|
|
249
|
+
console.log();
|
|
250
|
+
console.log(chalk5.dim("Uploading screenshots..."));
|
|
251
|
+
await upload(config, { branch, sha });
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(chalk5.dim("Creating review session..."));
|
|
254
|
+
const reviewUrl = `${config.server}/api/reviews`;
|
|
255
|
+
let reviewData;
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(reviewUrl, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "Content-Type": "application/json", Origin: config.server },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
project: config.project,
|
|
262
|
+
branch,
|
|
263
|
+
gitSha: sha
|
|
264
|
+
})
|
|
265
|
+
});
|
|
266
|
+
const data = await res.json();
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
console.error(chalk5.red(`Failed to create review: ${JSON.stringify(data)}`));
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
reviewData = data;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(chalk5.red(`Request failed: ${err}`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
const allZero = reviewData.diffs.length === 0 || reviewData.diffs.every((d) => d.mismatchPercentage === 0);
|
|
277
|
+
if (allZero) {
|
|
278
|
+
console.log(chalk5.green("No visual changes detected."));
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
const sessionUrl = `${config.server}/reviews/${reviewData.review.id}`;
|
|
282
|
+
console.log();
|
|
283
|
+
console.log(chalk5.bold("Visual changes detected:"));
|
|
284
|
+
console.log();
|
|
285
|
+
for (const d of reviewData.diffs) {
|
|
286
|
+
const pct = d.mismatchPercentage.toFixed(2) + "%";
|
|
287
|
+
const color = d.mismatchPercentage > 10 ? chalk5.red : d.mismatchPercentage > 1 ? chalk5.yellow : chalk5.green;
|
|
288
|
+
console.log(` ${d.screen.padEnd(30)} ${color(pct)}`);
|
|
289
|
+
}
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(`Review: ${chalk5.cyan(sessionUrl)}`);
|
|
292
|
+
if (shouldOpen) {
|
|
293
|
+
openBrowser(sessionUrl);
|
|
294
|
+
}
|
|
295
|
+
if (!shouldPoll) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.log();
|
|
299
|
+
console.log(chalk5.dim(`Polling for verdict (timeout: ${timeoutSec}s)...`));
|
|
300
|
+
const pollInterval = 3e3;
|
|
301
|
+
const startTime = Date.now();
|
|
302
|
+
const deadline = startTime + timeoutSec * 1e3;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
await sleep(pollInterval);
|
|
305
|
+
try {
|
|
306
|
+
const res = await fetch(`${config.server}/api/reviews/${reviewData.review.id}`, {
|
|
307
|
+
headers: { Origin: config.server }
|
|
308
|
+
});
|
|
309
|
+
const data = await res.json();
|
|
310
|
+
if (!res.ok) continue;
|
|
311
|
+
if (data.review.status === "approved") {
|
|
312
|
+
console.log();
|
|
313
|
+
console.log(chalk5.green.bold("Review approved!"));
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
if (data.review.status === "rejected") {
|
|
317
|
+
console.log();
|
|
318
|
+
console.log(chalk5.red.bold("Review rejected."));
|
|
319
|
+
const rejected = data.diffs.filter((d) => d.status === "rejected");
|
|
320
|
+
if (rejected.length > 0) {
|
|
321
|
+
console.log(chalk5.dim("Rejected screens:"));
|
|
322
|
+
for (const d of rejected) {
|
|
323
|
+
console.log(chalk5.red(` - ${d.base.screen_slug}`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
console.log();
|
|
332
|
+
console.log(chalk5.yellow(`Review timed out after ${timeoutSec}s.`));
|
|
333
|
+
console.log(chalk5.dim(`Visit ${sessionUrl} to complete the review.`));
|
|
334
|
+
process.exit(2);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/hook.ts
|
|
338
|
+
import { existsSync as existsSync3, writeFileSync as writeFileSync2, unlinkSync, chmodSync, readFileSync as readFileSync3 } from "fs";
|
|
339
|
+
import { resolve as resolve4, join } from "path";
|
|
340
|
+
import { execSync as execSync3 } from "child_process";
|
|
341
|
+
import chalk6 from "chalk";
|
|
342
|
+
var HOOK_MARKER = "# git-shots-hook";
|
|
343
|
+
var HOOK_SCRIPT = `#!/bin/sh
|
|
344
|
+
${HOOK_MARKER}
|
|
345
|
+
# Pre-push hook: runs git-shots visual review before pushing.
|
|
346
|
+
# Installed by: git-shots hook install
|
|
347
|
+
|
|
348
|
+
# Skip if no .git-shots.json config
|
|
349
|
+
if [ ! -f ".git-shots.json" ]; then
|
|
350
|
+
exit 0
|
|
351
|
+
fi
|
|
352
|
+
|
|
353
|
+
# Read screenshots directory from config (default: docs/screenshots/current)
|
|
354
|
+
SCREENSHOTS_DIR=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('.git-shots.json','utf-8'));console.log(c.directory||'docs/screenshots/current')}catch{console.log('docs/screenshots/current')}" 2>/dev/null)
|
|
355
|
+
|
|
356
|
+
# Skip if no screenshots directory or no PNGs
|
|
357
|
+
if [ ! -d "$SCREENSHOTS_DIR" ]; then
|
|
358
|
+
exit 0
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
PNG_COUNT=$(find "$SCREENSHOTS_DIR" -name "*.png" 2>/dev/null | head -1)
|
|
362
|
+
if [ -z "$PNG_COUNT" ]; then
|
|
363
|
+
exit 0
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
# Skip on main/master \u2014 no base to diff against
|
|
367
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
368
|
+
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
|
|
369
|
+
exit 0
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
echo ""
|
|
373
|
+
echo "git-shots: Running visual review before push..."
|
|
374
|
+
echo ""
|
|
375
|
+
|
|
376
|
+
git-shots review
|
|
377
|
+
EXIT_CODE=$?
|
|
378
|
+
|
|
379
|
+
if [ $EXIT_CODE -eq 1 ]; then
|
|
380
|
+
echo ""
|
|
381
|
+
echo "git-shots: Visual review rejected. Push blocked."
|
|
382
|
+
exit 1
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
if [ $EXIT_CODE -eq 2 ]; then
|
|
386
|
+
echo ""
|
|
387
|
+
echo "git-shots: Visual review timed out. Push allowed (review pending)."
|
|
388
|
+
exit 0
|
|
389
|
+
fi
|
|
390
|
+
|
|
391
|
+
exit 0
|
|
392
|
+
`;
|
|
393
|
+
function getGitDir(cwd) {
|
|
394
|
+
try {
|
|
395
|
+
return execSync3("git rev-parse --git-dir", { cwd, encoding: "utf-8" }).trim();
|
|
396
|
+
} catch {
|
|
397
|
+
throw new Error("Not a git repository");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function hookInstall(cwd = process.cwd()) {
|
|
401
|
+
const gitDir = getGitDir(cwd);
|
|
402
|
+
const hooksDir = resolve4(cwd, gitDir, "hooks");
|
|
403
|
+
const hookPath = join(hooksDir, "pre-push");
|
|
404
|
+
if (existsSync3(hookPath)) {
|
|
405
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
406
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
407
|
+
console.log(chalk6.yellow("git-shots pre-push hook is already installed."));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
console.error(
|
|
411
|
+
chalk6.red("A pre-push hook already exists at ") + chalk6.dim(hookPath)
|
|
412
|
+
);
|
|
413
|
+
console.error(
|
|
414
|
+
chalk6.dim("Remove or rename it first, then re-run this command.")
|
|
415
|
+
);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
writeFileSync2(hookPath, HOOK_SCRIPT, { mode: 493 });
|
|
419
|
+
try {
|
|
420
|
+
chmodSync(hookPath, 493);
|
|
421
|
+
} catch {
|
|
422
|
+
}
|
|
423
|
+
console.log(chalk6.green("Installed pre-push hook at ") + chalk6.dim(hookPath));
|
|
424
|
+
console.log();
|
|
425
|
+
console.log(chalk6.dim("The hook will run `git-shots review` before every push"));
|
|
426
|
+
console.log(chalk6.dim("when .git-shots.json is present and screenshots exist."));
|
|
427
|
+
}
|
|
428
|
+
async function hookUninstall(cwd = process.cwd()) {
|
|
429
|
+
const gitDir = getGitDir(cwd);
|
|
430
|
+
const hooksDir = resolve4(cwd, gitDir, "hooks");
|
|
431
|
+
const hookPath = join(hooksDir, "pre-push");
|
|
432
|
+
if (!existsSync3(hookPath)) {
|
|
433
|
+
console.log(chalk6.yellow("No pre-push hook found."));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
437
|
+
if (!existing.includes(HOOK_MARKER)) {
|
|
438
|
+
console.error(chalk6.red("Pre-push hook exists but was not installed by git-shots."));
|
|
439
|
+
console.error(chalk6.dim("Remove it manually if you want: ") + chalk6.dim(hookPath));
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
unlinkSync(hookPath);
|
|
443
|
+
console.log(chalk6.green("Removed git-shots pre-push hook."));
|
|
444
|
+
}
|
|
445
|
+
|
|
219
446
|
// src/index.ts
|
|
220
447
|
var program = new Command();
|
|
221
448
|
program.name("git-shots").description("CLI for git-shots visual regression platform").version("0.1.0");
|
|
@@ -260,4 +487,28 @@ program.command("pull-baselines").description("Download baseline screenshots fro
|
|
|
260
487
|
}
|
|
261
488
|
await pullBaselines(config, { branch: options.branch, output: options.output });
|
|
262
489
|
});
|
|
490
|
+
program.command("review").description("Upload screenshots, create review session, and poll for verdict").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--open", "Open review URL in browser", true).option("--no-open", "Do not open review URL in browser").option("--poll", "Poll for verdict and exit with code", true).option("--no-poll", "Do not poll for verdict").option("--timeout <seconds>", "Polling timeout in seconds", parseInt, 300).action(async (options) => {
|
|
491
|
+
const config = loadConfig();
|
|
492
|
+
if (options.project) config.project = options.project;
|
|
493
|
+
if (options.server) config.server = options.server;
|
|
494
|
+
if (options.directory) config.directory = options.directory;
|
|
495
|
+
if (!config.project) {
|
|
496
|
+
console.error("Error: project slug required. Use --project or .git-shots.json");
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
await review(config, {
|
|
500
|
+
branch: options.branch,
|
|
501
|
+
sha: options.sha,
|
|
502
|
+
open: options.open,
|
|
503
|
+
poll: options.poll,
|
|
504
|
+
timeout: options.timeout
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
var hook = program.command("hook").description("Manage git hooks for automatic visual review");
|
|
508
|
+
hook.command("install").description("Install a pre-push hook that runs git-shots review before each push").action(async () => {
|
|
509
|
+
await hookInstall();
|
|
510
|
+
});
|
|
511
|
+
hook.command("uninstall").description("Remove the git-shots pre-push hook").action(async () => {
|
|
512
|
+
await hookUninstall();
|
|
513
|
+
});
|
|
263
514
|
program.parse();
|
package/package.json
CHANGED
package/src/hook.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync, chmodSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
const HOOK_MARKER = '# git-shots-hook';
|
|
7
|
+
|
|
8
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
9
|
+
${HOOK_MARKER}
|
|
10
|
+
# Pre-push hook: runs git-shots visual review before pushing.
|
|
11
|
+
# Installed by: git-shots hook install
|
|
12
|
+
|
|
13
|
+
# Skip if no .git-shots.json config
|
|
14
|
+
if [ ! -f ".git-shots.json" ]; then
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Read screenshots directory from config (default: docs/screenshots/current)
|
|
19
|
+
SCREENSHOTS_DIR=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('.git-shots.json','utf-8'));console.log(c.directory||'docs/screenshots/current')}catch{console.log('docs/screenshots/current')}" 2>/dev/null)
|
|
20
|
+
|
|
21
|
+
# Skip if no screenshots directory or no PNGs
|
|
22
|
+
if [ ! -d "$SCREENSHOTS_DIR" ]; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
PNG_COUNT=$(find "$SCREENSHOTS_DIR" -name "*.png" 2>/dev/null | head -1)
|
|
27
|
+
if [ -z "$PNG_COUNT" ]; then
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Skip on main/master — no base to diff against
|
|
32
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
33
|
+
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
echo ""
|
|
38
|
+
echo "git-shots: Running visual review before push..."
|
|
39
|
+
echo ""
|
|
40
|
+
|
|
41
|
+
git-shots review
|
|
42
|
+
EXIT_CODE=$?
|
|
43
|
+
|
|
44
|
+
if [ $EXIT_CODE -eq 1 ]; then
|
|
45
|
+
echo ""
|
|
46
|
+
echo "git-shots: Visual review rejected. Push blocked."
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
if [ $EXIT_CODE -eq 2 ]; then
|
|
51
|
+
echo ""
|
|
52
|
+
echo "git-shots: Visual review timed out. Push allowed (review pending)."
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
exit 0
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
function getGitDir(cwd: string): string {
|
|
60
|
+
try {
|
|
61
|
+
return execSync('git rev-parse --git-dir', { cwd, encoding: 'utf-8' }).trim();
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error('Not a git repository');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function hookInstall(cwd: string = process.cwd()) {
|
|
68
|
+
const gitDir = getGitDir(cwd);
|
|
69
|
+
const hooksDir = resolve(cwd, gitDir, 'hooks');
|
|
70
|
+
const hookPath = join(hooksDir, 'pre-push');
|
|
71
|
+
|
|
72
|
+
// Check if a pre-push hook already exists
|
|
73
|
+
if (existsSync(hookPath)) {
|
|
74
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
75
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
76
|
+
console.log(chalk.yellow('git-shots pre-push hook is already installed.'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Another hook exists — don't overwrite
|
|
81
|
+
console.error(
|
|
82
|
+
chalk.red('A pre-push hook already exists at ') + chalk.dim(hookPath)
|
|
83
|
+
);
|
|
84
|
+
console.error(
|
|
85
|
+
chalk.dim('Remove or rename it first, then re-run this command.')
|
|
86
|
+
);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
91
|
+
|
|
92
|
+
// chmod on non-Windows (writeFileSync mode doesn't always stick)
|
|
93
|
+
try {
|
|
94
|
+
chmodSync(hookPath, 0o755);
|
|
95
|
+
} catch {
|
|
96
|
+
// Windows — mode already set via writeFileSync
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(chalk.green('Installed pre-push hook at ') + chalk.dim(hookPath));
|
|
100
|
+
console.log();
|
|
101
|
+
console.log(chalk.dim('The hook will run `git-shots review` before every push'));
|
|
102
|
+
console.log(chalk.dim('when .git-shots.json is present and screenshots exist.'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function hookUninstall(cwd: string = process.cwd()) {
|
|
106
|
+
const gitDir = getGitDir(cwd);
|
|
107
|
+
const hooksDir = resolve(cwd, gitDir, 'hooks');
|
|
108
|
+
const hookPath = join(hooksDir, 'pre-push');
|
|
109
|
+
|
|
110
|
+
if (!existsSync(hookPath)) {
|
|
111
|
+
console.log(chalk.yellow('No pre-push hook found.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
116
|
+
if (!existing.includes(HOOK_MARKER)) {
|
|
117
|
+
console.error(chalk.red('Pre-push hook exists but was not installed by git-shots.'));
|
|
118
|
+
console.error(chalk.dim('Remove it manually if you want: ') + chalk.dim(hookPath));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
unlinkSync(hookPath);
|
|
123
|
+
console.log(chalk.green('Removed git-shots pre-push hook.'));
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { upload } from './upload.js';
|
|
|
5
5
|
import { compare } from './compare.js';
|
|
6
6
|
import { status } from './status.js';
|
|
7
7
|
import { pullBaselines } from './pull-baselines.js';
|
|
8
|
+
import { review } from './review.js';
|
|
9
|
+
import { hookInstall, hookUninstall } from './hook.js';
|
|
8
10
|
|
|
9
11
|
const program = new Command();
|
|
10
12
|
|
|
@@ -86,4 +88,53 @@ program
|
|
|
86
88
|
await pullBaselines(config, { branch: options.branch, output: options.output });
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
program
|
|
92
|
+
.command('review')
|
|
93
|
+
.description('Upload screenshots, create review session, and poll for verdict')
|
|
94
|
+
.option('-p, --project <slug>', 'Project slug')
|
|
95
|
+
.option('-s, --server <url>', 'Server URL')
|
|
96
|
+
.option('-d, --directory <path>', 'Screenshots directory')
|
|
97
|
+
.option('-b, --branch <name>', 'Git branch (auto-detected)')
|
|
98
|
+
.option('--sha <hash>', 'Git SHA (auto-detected)')
|
|
99
|
+
.option('--open', 'Open review URL in browser', true)
|
|
100
|
+
.option('--no-open', 'Do not open review URL in browser')
|
|
101
|
+
.option('--poll', 'Poll for verdict and exit with code', true)
|
|
102
|
+
.option('--no-poll', 'Do not poll for verdict')
|
|
103
|
+
.option('--timeout <seconds>', 'Polling timeout in seconds', parseInt, 300)
|
|
104
|
+
.action(async (options) => {
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
if (options.project) config.project = options.project;
|
|
107
|
+
if (options.server) config.server = options.server;
|
|
108
|
+
if (options.directory) config.directory = options.directory;
|
|
109
|
+
if (!config.project) {
|
|
110
|
+
console.error('Error: project slug required. Use --project or .git-shots.json');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
await review(config, {
|
|
114
|
+
branch: options.branch,
|
|
115
|
+
sha: options.sha,
|
|
116
|
+
open: options.open,
|
|
117
|
+
poll: options.poll,
|
|
118
|
+
timeout: options.timeout
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const hook = program
|
|
123
|
+
.command('hook')
|
|
124
|
+
.description('Manage git hooks for automatic visual review');
|
|
125
|
+
|
|
126
|
+
hook
|
|
127
|
+
.command('install')
|
|
128
|
+
.description('Install a pre-push hook that runs git-shots review before each push')
|
|
129
|
+
.action(async () => {
|
|
130
|
+
await hookInstall();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
hook
|
|
134
|
+
.command('uninstall')
|
|
135
|
+
.description('Remove the git-shots pre-push hook')
|
|
136
|
+
.action(async () => {
|
|
137
|
+
await hookUninstall();
|
|
138
|
+
});
|
|
139
|
+
|
|
89
140
|
program.parse();
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import type { GitShotsConfig } from './config.js';
|
|
5
|
+
import { upload } from './upload.js';
|
|
6
|
+
|
|
7
|
+
interface ReviewOptions {
|
|
8
|
+
branch?: string;
|
|
9
|
+
sha?: string;
|
|
10
|
+
open?: boolean;
|
|
11
|
+
poll?: boolean;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReviewResponse {
|
|
16
|
+
review: {
|
|
17
|
+
id: number;
|
|
18
|
+
status: string;
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
diffs: Array<{
|
|
22
|
+
screen: string;
|
|
23
|
+
mismatchPercentage: number;
|
|
24
|
+
mismatchPixels: number;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ReviewStatusResponse {
|
|
29
|
+
review: {
|
|
30
|
+
id: number;
|
|
31
|
+
status: string;
|
|
32
|
+
};
|
|
33
|
+
diffs: Array<{
|
|
34
|
+
base: { screen_slug: string };
|
|
35
|
+
mismatch_percentage: number;
|
|
36
|
+
status: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openBrowser(url: string): void {
|
|
41
|
+
try {
|
|
42
|
+
const os = platform();
|
|
43
|
+
if (os === 'win32') {
|
|
44
|
+
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
45
|
+
} else if (os === 'darwin') {
|
|
46
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
47
|
+
} else {
|
|
48
|
+
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
console.log(chalk.dim(`Could not open browser. Visit the URL manually.`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number): Promise<void> {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function review(config: GitShotsConfig, options: ReviewOptions) {
|
|
60
|
+
const shouldOpen = options.open ?? true;
|
|
61
|
+
const shouldPoll = options.poll ?? true;
|
|
62
|
+
const timeoutSec = options.timeout ?? 300;
|
|
63
|
+
|
|
64
|
+
// Get git info
|
|
65
|
+
const branch =
|
|
66
|
+
options.branch ??
|
|
67
|
+
execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
68
|
+
const sha =
|
|
69
|
+
options.sha ?? execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
|
70
|
+
|
|
71
|
+
console.log(chalk.dim(`Project: ${config.project}`));
|
|
72
|
+
console.log(chalk.dim(`Branch: ${branch}`));
|
|
73
|
+
console.log(chalk.dim(`SHA: ${sha.slice(0, 7)}`));
|
|
74
|
+
console.log();
|
|
75
|
+
|
|
76
|
+
// Step 1: Upload screenshots
|
|
77
|
+
console.log(chalk.dim('Uploading screenshots...'));
|
|
78
|
+
await upload(config, { branch, sha });
|
|
79
|
+
console.log();
|
|
80
|
+
|
|
81
|
+
// Step 2: Create review session via POST /api/reviews
|
|
82
|
+
console.log(chalk.dim('Creating review session...'));
|
|
83
|
+
|
|
84
|
+
const reviewUrl = `${config.server}/api/reviews`;
|
|
85
|
+
let reviewData: ReviewResponse;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(reviewUrl, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json', Origin: config.server },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
project: config.project,
|
|
93
|
+
branch,
|
|
94
|
+
gitSha: sha
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
console.error(chalk.red(`Failed to create review: ${JSON.stringify(data)}`));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reviewData = data as ReviewResponse;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(chalk.red(`Request failed: ${err}`));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 3: Check if all diffs are 0% mismatch
|
|
111
|
+
const allZero =
|
|
112
|
+
reviewData.diffs.length === 0 ||
|
|
113
|
+
reviewData.diffs.every((d) => d.mismatchPercentage === 0);
|
|
114
|
+
|
|
115
|
+
if (allZero) {
|
|
116
|
+
console.log(chalk.green('No visual changes detected.'));
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Step 4: Print review URL and diff summary
|
|
121
|
+
const sessionUrl = `${config.server}/reviews/${reviewData.review.id}`;
|
|
122
|
+
console.log();
|
|
123
|
+
console.log(chalk.bold('Visual changes detected:'));
|
|
124
|
+
console.log();
|
|
125
|
+
|
|
126
|
+
for (const d of reviewData.diffs) {
|
|
127
|
+
const pct = d.mismatchPercentage.toFixed(2) + '%';
|
|
128
|
+
const color =
|
|
129
|
+
d.mismatchPercentage > 10
|
|
130
|
+
? chalk.red
|
|
131
|
+
: d.mismatchPercentage > 1
|
|
132
|
+
? chalk.yellow
|
|
133
|
+
: chalk.green;
|
|
134
|
+
console.log(` ${d.screen.padEnd(30)} ${color(pct)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log();
|
|
138
|
+
console.log(`Review: ${chalk.cyan(sessionUrl)}`);
|
|
139
|
+
|
|
140
|
+
// Step 5: Open in browser
|
|
141
|
+
if (shouldOpen) {
|
|
142
|
+
openBrowser(sessionUrl);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Step 6: Poll for verdict
|
|
146
|
+
if (!shouldPoll) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(chalk.dim(`Polling for verdict (timeout: ${timeoutSec}s)...`));
|
|
152
|
+
|
|
153
|
+
const pollInterval = 3000;
|
|
154
|
+
const startTime = Date.now();
|
|
155
|
+
const deadline = startTime + timeoutSec * 1000;
|
|
156
|
+
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
await sleep(pollInterval);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`${config.server}/api/reviews/${reviewData.review.id}`, {
|
|
162
|
+
headers: { Origin: config.server }
|
|
163
|
+
});
|
|
164
|
+
const data = (await res.json()) as ReviewStatusResponse;
|
|
165
|
+
|
|
166
|
+
if (!res.ok) continue;
|
|
167
|
+
|
|
168
|
+
if (data.review.status === 'approved') {
|
|
169
|
+
console.log();
|
|
170
|
+
console.log(chalk.green.bold('Review approved!'));
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (data.review.status === 'rejected') {
|
|
175
|
+
console.log();
|
|
176
|
+
console.log(chalk.red.bold('Review rejected.'));
|
|
177
|
+
|
|
178
|
+
// Show which screens had issues
|
|
179
|
+
const rejected = data.diffs.filter((d) => d.status === 'rejected');
|
|
180
|
+
if (rejected.length > 0) {
|
|
181
|
+
console.log(chalk.dim('Rejected screens:'));
|
|
182
|
+
for (const d of rejected) {
|
|
183
|
+
console.log(chalk.red(` - ${d.base.screen_slug}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Network error — keep polling
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Timeout
|
|
195
|
+
console.log();
|
|
196
|
+
console.log(chalk.yellow(`Review timed out after ${timeoutSec}s.`));
|
|
197
|
+
console.log(chalk.dim(`Visit ${sessionUrl} to complete the review.`));
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|