@motivation-labs/crosscheck 0.4.0 → 0.4.1-beta.1216e4e.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.
Files changed (90) hide show
  1. package/README.md +26 -9
  2. package/crosscheck.config.example.yml +98 -5
  3. package/dist/__tests__/backtrace.test.d.ts +2 -0
  4. package/dist/__tests__/backtrace.test.d.ts.map +1 -0
  5. package/dist/__tests__/backtrace.test.js +158 -0
  6. package/dist/__tests__/backtrace.test.js.map +1 -0
  7. package/dist/__tests__/loader.test.d.ts +2 -0
  8. package/dist/__tests__/loader.test.d.ts.map +1 -0
  9. package/dist/__tests__/loader.test.js +131 -0
  10. package/dist/__tests__/loader.test.js.map +1 -0
  11. package/dist/__tests__/optimize.test.js +16 -3
  12. package/dist/__tests__/optimize.test.js.map +1 -1
  13. package/dist/ck.d.ts +3 -0
  14. package/dist/ck.d.ts.map +1 -0
  15. package/dist/ck.js +8 -0
  16. package/dist/ck.js.map +1 -0
  17. package/dist/cli.js +12 -4
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/diagnose.d.ts +1 -0
  20. package/dist/commands/diagnose.d.ts.map +1 -1
  21. package/dist/commands/diagnose.js +14 -0
  22. package/dist/commands/diagnose.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +63 -29
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/review.d.ts.map +1 -1
  27. package/dist/commands/review.js +12 -6
  28. package/dist/commands/review.js.map +1 -1
  29. package/dist/commands/serve.d.ts +7 -1
  30. package/dist/commands/serve.d.ts.map +1 -1
  31. package/dist/commands/serve.js +152 -34
  32. package/dist/commands/serve.js.map +1 -1
  33. package/dist/commands/status.d.ts.map +1 -1
  34. package/dist/commands/status.js +1 -0
  35. package/dist/commands/status.js.map +1 -1
  36. package/dist/commands/watch.d.ts +7 -1
  37. package/dist/commands/watch.d.ts.map +1 -1
  38. package/dist/commands/watch.js +348 -135
  39. package/dist/commands/watch.js.map +1 -1
  40. package/dist/config/loader.d.ts +10 -0
  41. package/dist/config/loader.d.ts.map +1 -1
  42. package/dist/config/loader.js +196 -0
  43. package/dist/config/loader.js.map +1 -1
  44. package/dist/config/schema.d.ts +461 -35
  45. package/dist/config/schema.d.ts.map +1 -1
  46. package/dist/config/schema.js +72 -5
  47. package/dist/config/schema.js.map +1 -1
  48. package/dist/github/client.d.ts +26 -0
  49. package/dist/github/client.d.ts.map +1 -1
  50. package/dist/github/client.js +159 -2
  51. package/dist/github/client.js.map +1 -1
  52. package/dist/github/detector.d.ts +9 -2
  53. package/dist/github/detector.d.ts.map +1 -1
  54. package/dist/github/detector.js +86 -10
  55. package/dist/github/detector.js.map +1 -1
  56. package/dist/lib/backtrace.d.ts +20 -0
  57. package/dist/lib/backtrace.d.ts.map +1 -0
  58. package/dist/lib/backtrace.js +75 -0
  59. package/dist/lib/backtrace.js.map +1 -0
  60. package/dist/lib/board.d.ts +54 -0
  61. package/dist/lib/board.d.ts.map +1 -0
  62. package/dist/lib/board.js +406 -0
  63. package/dist/lib/board.js.map +1 -0
  64. package/dist/lib/runner.d.ts +10 -1
  65. package/dist/lib/runner.d.ts.map +1 -1
  66. package/dist/lib/runner.js +129 -51
  67. package/dist/lib/runner.js.map +1 -1
  68. package/dist/lib/verdict.d.ts +1 -0
  69. package/dist/lib/verdict.d.ts.map +1 -1
  70. package/dist/lib/verdict.js +27 -7
  71. package/dist/lib/verdict.js.map +1 -1
  72. package/dist/lib/workflow.d.ts +14 -14
  73. package/dist/lib/workflow.d.ts.map +1 -1
  74. package/dist/lib/workflow.js +22 -5
  75. package/dist/lib/workflow.js.map +1 -1
  76. package/dist/reviewers/claude.d.ts +1 -1
  77. package/dist/reviewers/claude.d.ts.map +1 -1
  78. package/dist/reviewers/claude.js +4 -6
  79. package/dist/reviewers/claude.js.map +1 -1
  80. package/dist/reviewers/codex.d.ts +2 -2
  81. package/dist/reviewers/codex.d.ts.map +1 -1
  82. package/dist/reviewers/codex.js +6 -6
  83. package/dist/reviewers/codex.js.map +1 -1
  84. package/dist/reviewers/fix.d.ts +5 -0
  85. package/dist/reviewers/fix.d.ts.map +1 -0
  86. package/dist/reviewers/fix.js +87 -0
  87. package/dist/reviewers/fix.js.map +1 -0
  88. package/get-started.md +202 -23
  89. package/get-started.zh.md +2 -3
  90. package/package.json +4 -3
@@ -1,17 +1,39 @@
1
1
  import { execSync, spawn } from 'child_process';
2
2
  import chalk from 'chalk';
3
- import ora from 'ora';
4
3
  import { createWebhookServer } from '../github/webhook.js';
5
- import { registerOrgWebhook, deleteOrgWebhook, registerRepoWebhook, deleteRepoWebhook, } from '../github/client.js';
6
- import { detectPROrigin, assignReviewer } from '../github/detector.js';
7
- import { loadConfig, getGithubToken, getWebhookSecret, resolveConfigPath } from '../config/loader.js';
4
+ import { registerOrgWebhook, deleteOrgWebhook, registerRepoWebhook, deleteRepoWebhook, findOrgWebhook, findRepoWebhook, listUserRepos, checkRepoAccessible, } from '../github/client.js';
5
+ import { detectOriginFull, assignReviewer } from '../github/detector.js';
6
+ import { loadConfig, getGithubToken, getWebhookSecret, resolveConfigPath, promptDeploymentMode, detectScopesForDeployment, patchDeploymentConfig, detectGitHubLogin, } from '../config/loader.js';
8
7
  import { randomFortune } from '../lib/fortune.js';
8
+ import { scanUnreviewedPRs } from '../lib/backtrace.js';
9
9
  import { initLogger, log as fileLog, logError, logUncaught } from '../lib/logger.js';
10
10
  import { isAuthorAllowed } from '../lib/filter.js';
11
11
  import { runWorkflow } from '../lib/runner.js';
12
+ import { loadWorkflow } from '../lib/workflow.js';
13
+ import { PRBoard, fmtTime, FMT_TIME_WIDTH } from '../lib/board.js';
12
14
  import { mkdtempSync, rmSync } from 'fs';
13
15
  import { tmpdir } from 'os';
14
16
  import { join } from 'path';
17
+ // Compute PR diff size in lines, excluding noise (lockfiles, binaries, data files)
18
+ const NOISE_EXT = /\.(lock|snap|min\.js|min\.css|csv|json|png|jpg|jpeg|gif|svg|mp4|woff2?|ttf|eot|ico|pdf)$/i;
19
+ function computePRLoc(tmpDir, baseBranch) {
20
+ try {
21
+ const stat = execSync(`git diff --stat origin/${baseBranch}...HEAD`, { cwd: tmpDir, encoding: 'utf8' });
22
+ let total = 0;
23
+ for (const line of stat.split('\n')) {
24
+ const m = line.match(/^\s+(.+?)\s+\|\s+(\d+)/);
25
+ if (!m)
26
+ continue;
27
+ const file = m[1].trim().replace(/\{.*?=> /, '').replace('}', ''); // handle rename notation
28
+ if (!NOISE_EXT.test(file))
29
+ total += parseInt(m[2], 10);
30
+ }
31
+ return total;
32
+ }
33
+ catch {
34
+ return 0;
35
+ }
36
+ }
15
37
  function detectCurrentRepo() {
16
38
  try {
17
39
  const remote = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim();
@@ -81,8 +103,9 @@ function openTunnel(localPort) {
81
103
  });
82
104
  });
83
105
  }
84
- export async function runWatch(configPath) {
85
- const config = loadConfig(configPath);
106
+ export async function runWatch(opts = {}) {
107
+ const configPath = opts.config;
108
+ let config = loadConfig(configPath);
86
109
  initLogger(config.logs);
87
110
  process.on('uncaughtException', (err) => {
88
111
  logUncaught('uncaughtException', err);
@@ -106,14 +129,104 @@ export async function runWatch(configPath) {
106
129
  fileLog({ level: 'info', event: 'session_start', command: 'watch' });
107
130
  const webhookSecret = getWebhookSecret();
108
131
  const webhookPath = config.server.webhook_path;
109
- const log = (msg) => {
110
- console.log(`${chalk.dim(new Date().toLocaleTimeString())} ${msg}`);
111
- fileLog({ level: 'info', event: 'message', message: msg });
132
+ // Board manages all terminal output after startup
133
+ const board = new PRBoard();
134
+ board.setConfig(config, loadWorkflow(process.cwd()));
135
+ // Thin wrapper: routes important messages to both terminal and file log
136
+ const bLog = (line1, line2) => {
137
+ board.log(line1, line2);
138
+ fileLog({ level: 'info', event: 'message', message: line2 ? `${line1} ${line2}` : line1 });
139
+ };
140
+ // Connectivity events (tunnel/webhook) go into the live connectivity section
141
+ const cLog = (line) => {
142
+ board.logConnectivity(line);
143
+ fileLog({ level: 'info', event: 'message', message: line });
112
144
  };
113
145
  // PR deduplication — skip if already reviewing this PR+SHA
114
146
  const inFlight = new Set();
115
147
  // SHAs pushed by the address step — skip synchronize events from our own commits
116
148
  const crosscheckShas = new Set();
149
+ async function reviewPR(params) {
150
+ const { owner, repoName, prNumber } = params;
151
+ const key = `${owner}/${repoName}#${prNumber}@${params.headSha}`;
152
+ if (inFlight.has(key))
153
+ return;
154
+ inFlight.add(key);
155
+ // Outer try/finally ensures the inFlight key is always released, even if
156
+ // detectOriginFull / assignReviewer throw before the inner try block starts.
157
+ try {
158
+ if (!isAuthorAllowed(config.routing.allowed_authors, params.author)) {
159
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'author_not_allowed', author: params.author });
160
+ return;
161
+ }
162
+ const { origin, method: originMethod } = await detectOriginFull(params.body ?? '', params.headRef, owner, repoName, prNumber, config, token, params.author);
163
+ const reviewer = await assignReviewer(origin, config);
164
+ fileLog({ level: 'info', event: 'pr_received', repo: `${owner}/${repoName}`, pr: prNumber, sha: params.headSha, action: params.action, origin, origin_method: originMethod, author: params.author });
165
+ if (!reviewer) {
166
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'no_reviewer', origin });
167
+ return;
168
+ }
169
+ const ts = chalk.dim(fmtTime());
170
+ const tsIndent = ' '.repeat(FMT_TIME_WIDTH + 2);
171
+ bLog(`${ts} PR #${prNumber} ${params.action} ${chalk.dim(params.title)}`, `${tsIndent}origin=${chalk.yellow(origin)} via=${chalk.dim(originMethod)} reviewer=${chalk.cyan(reviewer)}`);
172
+ const pr = {
173
+ title: params.title,
174
+ body: params.body ?? '',
175
+ head: { ref: params.headRef, sha: params.headSha, repo: params.headRepo ? { full_name: params.headRepo } : null },
176
+ base: { ref: params.baseRef, repo: { full_name: `${owner}/${repoName}` } },
177
+ html_url: `https://github.com/${owner}/${repoName}/pull/${prNumber}`,
178
+ user: { login: params.author },
179
+ };
180
+ board.addPR(key, prNumber, `${owner}/${repoName}`, params.headRef);
181
+ const reviewStart = Date.now();
182
+ const tmpDir = mkdtempSync(join(tmpdir(), 'crosscheck-repo-'));
183
+ try {
184
+ execSync(`gh repo clone ${owner}/${repoName} ${tmpDir} -- --depth=50 --quiet`, { stdio: 'pipe', env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token } });
185
+ execSync(`git fetch origin pull/${prNumber}/head:pr-${prNumber}`, { cwd: tmpDir, stdio: 'pipe' });
186
+ execSync(`git checkout pr-${prNumber}`, { cwd: tmpDir, stdio: 'pipe' });
187
+ // Fetch the base branch after checking out the PR branch so we are never
188
+ // on the base branch during the fetch (git refuses to update a checked-out ref).
189
+ // Use explicit refs/remotes/origin/<base> target so the remote-tracking ref is
190
+ // always created — `git fetch origin <branch>` alone only writes FETCH_HEAD in
191
+ // shallow clones when the branch is absent from the default refspec mapping.
192
+ try {
193
+ execSync(`git fetch origin ${params.baseRef}:refs/remotes/origin/${params.baseRef}`, { cwd: tmpDir, stdio: 'pipe' });
194
+ }
195
+ catch {
196
+ fileLog({ level: 'warn', event: 'base_branch_fetch_skipped', repo: `${owner}/${repoName}`, pr: prNumber, base: params.baseRef });
197
+ }
198
+ const prLoc = computePRLoc(tmpDir, params.baseRef);
199
+ board.updatePR(key, { prLoc });
200
+ const { verdict } = await runWorkflow({
201
+ owner, repoName, prNumber, pr,
202
+ tmpDir, token, config, origin,
203
+ reviewStart,
204
+ log: (msg) => bLog(`${chalk.dim(fmtTime())} ${msg}`),
205
+ onPhaseChange: (label, data) => board.updatePR(key, { label, ...data }),
206
+ crosscheckShas,
207
+ });
208
+ void verdict;
209
+ board.completePR(key, {
210
+ elapsedMs: Date.now() - reviewStart,
211
+ url: `github.com/${owner}/${repoName}/pull/${prNumber}`,
212
+ });
213
+ }
214
+ catch (err) {
215
+ const message = err instanceof Error ? err.message : String(err);
216
+ board.failPR(key, message);
217
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'review' }, err);
218
+ }
219
+ finally {
220
+ rmSync(tmpDir, { force: true, recursive: true });
221
+ }
222
+ }
223
+ catch (err) {
224
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'setup' }, err);
225
+ }
226
+ finally {
227
+ inFlight.delete(key);
228
+ }
229
+ }
117
230
  // Start local webhook server
118
231
  const server = createWebhookServer(config, webhookSecret, async (event) => {
119
232
  const { pull_request: pr, repository: repo } = event;
@@ -122,7 +235,7 @@ export async function runWatch(configPath) {
122
235
  const prNumber = event.number;
123
236
  const key = `${owner}/${repoName}#${prNumber}@${pr.head.sha}`;
124
237
  if (inFlight.has(key)) {
125
- log(chalk.dim(`PR #${prNumber} already in review skipping duplicate`));
238
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'duplicate' });
126
239
  return;
127
240
  }
128
241
  // Skip synchronize events triggered by our own address commits
@@ -130,61 +243,13 @@ export async function runWatch(configPath) {
130
243
  fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'crosscheck_sha', sha: pr.head.sha });
131
244
  return;
132
245
  }
133
- inFlight.add(key);
134
- const author = pr.user.login;
135
- if (!isAuthorAllowed(config.routing.allowed_authors, author)) {
136
- fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'author_not_allowed', author });
137
- inFlight.delete(key);
138
- return;
139
- }
140
- log(`${chalk.bold(`PR #${prNumber}`)} ${event.action}: ${chalk.dim(pr.title)}`);
141
- const origin = detectPROrigin(pr.body ?? '', config);
142
- const reviewer = assignReviewer(origin, config);
143
- fileLog({ level: 'info', event: 'pr_received', repo: `${owner}/${repoName}`, pr: prNumber, sha: pr.head.sha, action: event.action, origin, author });
144
- if (!reviewer) {
145
- log(chalk.dim(` origin=${origin} — skipping (no reviewer assigned)`));
146
- inFlight.delete(key);
147
- return;
148
- }
149
- log(` origin=${chalk.yellow(origin)} reviewer=${chalk.cyan(reviewer)}`);
150
- const tmpDir = mkdtempSync(join(tmpdir(), 'crosscheck-repo-'));
151
- const spinner = ora({ indent: 2 });
152
- const reviewStart = Date.now();
153
- try {
154
- spinner.start('cloning...');
155
- execSync(`gh repo clone ${owner}/${repoName} ${tmpDir} -- --depth=50 --quiet`, { stdio: 'pipe', env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token } });
156
- execSync(`git fetch origin pull/${prNumber}/head:pr-${prNumber}`, { cwd: tmpDir, stdio: 'pipe' });
157
- execSync(`git checkout pr-${prNumber}`, { cwd: tmpDir, stdio: 'pipe' });
158
- // Fetch the base branch after checking out the PR branch so we are never
159
- // on the base branch during the fetch (git refuses to update a checked-out ref).
160
- try {
161
- execSync(`git fetch origin ${pr.base.ref}:${pr.base.ref}`, { cwd: tmpDir, stdio: 'pipe' });
162
- }
163
- catch {
164
- fileLog({ level: 'warn', event: 'base_branch_fetch_skipped', repo: `${owner}/${repoName}`, pr: prNumber, base: pr.base.ref });
165
- }
166
- spinner.succeed('cloned');
167
- await runWorkflow({
168
- owner, repoName, prNumber, pr,
169
- tmpDir, token, config, origin,
170
- reviewStart,
171
- log,
172
- crosscheckShas,
173
- });
174
- }
175
- catch (err) {
176
- const message = err instanceof Error ? err.message : String(err);
177
- if (spinner.isSpinning)
178
- spinner.fail(message);
179
- else
180
- log(` ✗ ${message}`);
181
- logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'review' }, err);
182
- }
183
- finally {
184
- rmSync(tmpDir, { force: true, recursive: true });
185
- inFlight.delete(key);
186
- }
187
- }, log, fileLog);
246
+ await reviewPR({
247
+ owner, repoName, prNumber,
248
+ title: pr.title, body: pr.body, author: pr.user.login,
249
+ headSha: pr.head.sha, headRef: pr.head.ref, headRepo: pr.head.repo?.full_name ?? null,
250
+ baseRef: pr.base.ref, action: event.action,
251
+ });
252
+ }, (msg) => bLog(chalk.dim(fmtTime()) + ' ' + msg), fileLog);
188
253
  await new Promise((resolve, reject) => {
189
254
  server.on('error', (err) => {
190
255
  if (err.code === 'EADDRINUSE') {
@@ -201,21 +266,72 @@ export async function runWatch(configPath) {
201
266
  console.error(chalk.red(`\n✗ ${err.message}`));
202
267
  process.exit(1);
203
268
  });
269
+ // ── Deployment setup ─────────────────────────────────────────────────────
270
+ // Runs before scope building so detected users/orgs feed into webhook registration.
271
+ let effectiveDeployment = config.deployment;
272
+ let sessionOnly = false;
273
+ let selfLogin = null;
274
+ if (opts.personal || opts.team) {
275
+ // One-time flag: auto-detect scopes for this session, no config write.
276
+ effectiveDeployment = opts.personal ? 'personal' : 'team';
277
+ sessionOnly = true;
278
+ const detected = await detectScopesForDeployment(effectiveDeployment, token);
279
+ selfLogin = detected.login;
280
+ config = { ...config, users: detected.users, orgs: detected.orgs, repos: [] };
281
+ }
282
+ else if (opts.reconfigure || !config.deployment) {
283
+ // First run (no deployment in config) or explicit --reconfigure.
284
+ effectiveDeployment = await promptDeploymentMode(opts.reconfigure ? config.deployment : undefined);
285
+ const cfgPath = resolveConfigPath(configPath) ?? join(process.cwd(), 'crosscheck.config.yml');
286
+ const detected = await detectScopesForDeployment(effectiveDeployment, token);
287
+ selfLogin = detected.login;
288
+ // force=true only for --reconfigure; first-run preserves any manually-configured orgs/authors
289
+ patchDeploymentConfig(cfgPath, effectiveDeployment, detected.login, detected.orgs, !!opts.reconfigure);
290
+ config = loadConfig(configPath);
291
+ console.log(`\n ${chalk.green('✓')} deployment set to ${chalk.cyan(effectiveDeployment)} ${chalk.dim(`(saved to ${cfgPath})`)}`);
292
+ }
204
293
  const scopes = [];
205
- if (config.orgs.length > 0) {
206
- for (const org of config.orgs)
207
- scopes.push({ org });
294
+ for (const org of config.orgs)
295
+ scopes.push({ org });
296
+ const userRepoResults = [];
297
+ if (config.users.length > 0) {
298
+ // selfLogin is known when we just ran detection; fall back to detectGitHubLogin() for
299
+ // existing configs so personal-mode users still get private repos enumerated.
300
+ if (!selfLogin)
301
+ selfLogin = detectGitHubLogin();
302
+ for (const user of config.users) {
303
+ try {
304
+ const repos = await listUserRepos(user, token, user === selfLogin);
305
+ for (const { owner, name } of repos)
306
+ scopes.push({ owner, repo: name });
307
+ userRepoResults.push({ user, count: repos.length });
308
+ }
309
+ catch (err) {
310
+ const msg = err instanceof Error ? err.message : String(err);
311
+ userRepoResults.push({ user, error: msg });
312
+ }
313
+ }
208
314
  }
209
- else if (config.repos.length > 0) {
210
- for (const { owner, name } of config.repos)
315
+ // Validate explicitly-configured repos and skip any that are inaccessible.
316
+ const repoChecks = await Promise.all(config.repos.map(async ({ owner, name }) => ({
317
+ owner, name,
318
+ ok: await checkRepoAccessible(owner, name, token).catch(() => false),
319
+ })));
320
+ for (const { owner, name, ok } of repoChecks) {
321
+ if (ok) {
211
322
  scopes.push({ owner, repo: name });
323
+ }
324
+ else {
325
+ console.log(chalk.yellow(` ✗ repo not accessible: ${owner}/${name} — skipped`));
326
+ fileLog({ level: 'warn', event: 'repo_inaccessible', repo: `${owner}/${name}` });
327
+ }
212
328
  }
213
- else if (config.tunnel.backend !== 'smee') {
329
+ if (scopes.length === 0 && config.tunnel.backend !== 'smee') {
214
330
  // localhost.run needs a target repo to auto-register webhooks.
215
331
  // smee users register the webhook manually — no target required here.
216
332
  const detected = detectCurrentRepo();
217
333
  if (!detected) {
218
- console.error(chalk.red('No repos or orgs configured. Run inside a git repo or set repos/orgs in config.'));
334
+ console.error(chalk.red('No repos, users, or orgs configured. Run inside a git repo or set repos/users/orgs in config.'));
219
335
  server.close(() => process.exit(1));
220
336
  return;
221
337
  }
@@ -241,6 +357,7 @@ export async function runWatch(configPath) {
241
357
  }
242
358
  const cleanup = async () => {
243
359
  running = false;
360
+ board.stop();
244
361
  console.log('\nCleaning up...');
245
362
  currentTunnelProc?.kill();
246
363
  await deleteCurrentWebhooks();
@@ -249,50 +366,126 @@ export async function runWatch(configPath) {
249
366
  };
250
367
  process.on('SIGINT', () => { void cleanup(); });
251
368
  process.on('SIGTERM', () => { void cleanup(); });
252
- // Print banner once at startup
369
+ // ── Static startup banner ─────────────────────────────────────────────────
253
370
  console.log(chalk.dim(`\n "${randomFortune()}"\n`));
254
371
  console.log(chalk.bold('crosscheck watch\n'));
255
- if (config.orgs.length > 0) {
256
- console.log(` orgs ${chalk.cyan(config.orgs.join(', '))}`);
372
+ if (effectiveDeployment) {
373
+ const deployLabel = sessionOnly
374
+ ? chalk.dim(`${effectiveDeployment} (session only — not saved)`)
375
+ : chalk.cyan(effectiveDeployment);
376
+ console.log(` profile ${deployLabel} · ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
257
377
  }
258
378
  else {
379
+ console.log(` profile ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
380
+ }
381
+ if (config.orgs.length > 0) {
382
+ console.log(` orgs ${chalk.cyan(config.orgs.join(', '))}`);
383
+ }
384
+ if (config.users.length > 0) {
385
+ const userParts = userRepoResults.map(r => {
386
+ if ('error' in r)
387
+ return chalk.yellow(`${r.user} (⚠ list failed)`);
388
+ return `${chalk.cyan(r.user)} ${chalk.dim(`(${r.count} repos)`)}`;
389
+ });
390
+ console.log(` users ${userParts.join(', ')}`);
391
+ }
392
+ if (config.orgs.length === 0 && config.users.length === 0) {
259
393
  const labels = scopes.map(s => 'org' in s ? s.org : `${s.owner}/${s.repo}`);
260
- console.log(` repos ${chalk.cyan(labels.join(', '))}`);
394
+ console.log(` repos ${chalk.cyan(labels.join(', '))}`);
261
395
  }
262
- console.log(` mode ${chalk.cyan(config.mode)}`);
263
- console.log(` quality ${chalk.cyan(config.quality.tier)}`);
264
396
  const cfgPath = resolveConfigPath(configPath);
265
- console.log(` config ${chalk.dim(cfgPath ?? 'none (using defaults)')} ${chalk.dim('← edit to change above')}`);
266
- if (config.routing.allowed_authors.length === 0) {
397
+ console.log(` config ${chalk.dim(cfgPath ?? 'none (using defaults)')} ${chalk.dim('← edit to change above')}`);
398
+ if (effectiveDeployment === 'team' && config.routing.allowed_authors.length === 0) {
399
+ console.log(` authors ${chalk.dim('all PRs (team mode)')}`);
400
+ }
401
+ else if (config.routing.allowed_authors.length > 0) {
402
+ console.log(` authors ${chalk.cyan(config.routing.allowed_authors.join(', '))}`);
403
+ }
404
+ else {
267
405
  console.log();
268
406
  console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('No author filter set — all PRs in monitored orgs/repos will be reviewed.')}`);
269
- console.log(` ${chalk.dim('Add to config:')} ${chalk.cyan('routing:\n allowed_authors:\n - your-github-login')}`);
270
- console.log(` ${chalk.dim('Or run')} ${chalk.cyan('crosscheck init')} ${chalk.dim('to auto-detect and apply.')}`);
407
+ console.log(` ${chalk.dim('Run')} ${chalk.cyan('crosscheck watch --reconfigure')} ${chalk.dim('to set up a deployment mode.')}`);
271
408
  }
272
409
  console.log();
410
+ // Board starts after the banner — all output below is live-updated
411
+ board.start();
412
+ // ── Backtrace scan ────────────────────────────────────────────────────────
413
+ if (config.backtrace.enabled) {
414
+ void (async () => {
415
+ try {
416
+ cLog(`${chalk.dim('✦')} backtrace: scanning open PRs in monitored scope...`);
417
+ const { queued, alreadyReviewed, skippedAuthor } = await scanUnreviewedPRs(scopes, config, token);
418
+ cLog(`${chalk.dim('✦')} backtrace: ${queued.length} unreviewed, ${alreadyReviewed} already reviewed, ${skippedAuthor} skipped (author filter)`);
419
+ void Promise.all(queued.map(pr => reviewPR({
420
+ owner: pr.owner, repoName: pr.repo, prNumber: pr.number,
421
+ title: pr.title, body: pr.body, author: pr.author,
422
+ headSha: pr.headSha, headRef: pr.headRef, headRepo: pr.headRepo,
423
+ baseRef: pr.baseRef, action: 'backtrace',
424
+ })));
425
+ }
426
+ catch (err) {
427
+ const msg = err instanceof Error ? err.message : String(err);
428
+ cLog(`${chalk.yellow('⚠')} backtrace: scan failed — ${msg}`);
429
+ }
430
+ })();
431
+ }
273
432
  // ── Smee mode ─────────────────────────────────────────────────────────────
274
- // No tunnel management or webhook auto-registration needed.
275
- // The user points their GitHub webhook to the smee channel URL once.
433
+ // Smee channel URL is stable webhooks are registered once and survive restarts.
276
434
  if (config.tunnel.backend === 'smee') {
277
435
  const channelUrl = config.tunnel.smee_channel;
278
436
  if (!channelUrl) {
437
+ board.stop();
279
438
  console.error(chalk.red('✗ tunnel.smee_channel is required when tunnel.backend: smee'));
280
439
  console.error(chalk.dim(' Visit https://smee.io/new to get a free channel URL.'));
281
440
  server.close(() => process.exit(1));
282
441
  return;
283
442
  }
284
- console.log(` tunnel ${chalk.cyan(channelUrl)} ${chalk.dim('(smee.io events queued while offline)')}`);
285
- console.log(chalk.dim(` Register this as GitHub webhook Payload URL, then:`));
286
- console.log(chalk.dim(` webhook secret: cat ~/.crosscheck/webhook-secret`));
287
- console.log(chalk.dim('Waiting for PR events — Ctrl+C to stop.\n'));
443
+ board.setTunnel('smee', channelUrl, true);
288
444
  fileLog({ level: 'info', event: 'tunnel_opened', url: channelUrl, backend: 'smee' });
445
+ // Register webhooks pointing at the smee channel URL (idempotent — skip if already set).
446
+ // The smee channel URL never changes, so this survives restarts without creating duplicates.
447
+ for (const scope of scopes) {
448
+ const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
449
+ try {
450
+ let existing;
451
+ if ('org' in scope) {
452
+ existing = await findOrgWebhook(scope.org, channelUrl, token);
453
+ if (!existing)
454
+ await registerOrgWebhook(scope.org, channelUrl, webhookSecret, token);
455
+ }
456
+ else {
457
+ existing = await findRepoWebhook(scope.owner, scope.repo, channelUrl, token);
458
+ if (!existing)
459
+ await registerRepoWebhook(scope.owner, scope.repo, channelUrl, webhookSecret, token);
460
+ }
461
+ cLog(`${chalk.green('✓')} webhook ${existing ? 'active' : 'registered'}: ${chalk.cyan(label)}`);
462
+ fileLog({ level: 'info', event: existing ? 'webhook_active' : 'webhook_registered', scope: label, url: channelUrl });
463
+ }
464
+ catch (err) {
465
+ const msg = err instanceof Error ? err.message : String(err);
466
+ const isCreds = /bad credentials|\[401\]/i.test(msg);
467
+ const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(msg)
468
+ || ('org' in scope && /\[404\]/i.test(msg));
469
+ cLog(`${chalk.yellow('⚠')} webhook failed: ${chalk.yellow(label)}`);
470
+ if (isCreds) {
471
+ cLog(` token invalid — run: ${chalk.cyan('gh auth refresh')}`);
472
+ }
473
+ else if (isScope) {
474
+ cLog(` missing admin:org_hook scope — run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
475
+ }
476
+ else {
477
+ cLog(` ${msg}`);
478
+ }
479
+ fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: msg });
480
+ }
481
+ }
289
482
  let smeeRetryDelay = 5_000;
290
483
  while (running) {
291
484
  const smeeProc = spawn('smee', [
292
485
  '--url', channelUrl,
293
486
  '--path', config.server.webhook_path,
294
487
  '--port', String(config.server.port),
295
- ], { stdio: 'inherit' });
488
+ ], { stdio: 'pipe' });
296
489
  currentTunnelProc = smeeProc;
297
490
  try {
298
491
  await new Promise((resolve, reject) => {
@@ -308,6 +501,7 @@ export async function runWatch(configPath) {
308
501
  });
309
502
  }
310
503
  catch (err) {
504
+ board.stop();
311
505
  console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
312
506
  server.close(() => process.exit(1));
313
507
  return;
@@ -315,17 +509,19 @@ export async function runWatch(configPath) {
315
509
  if (!running)
316
510
  break;
317
511
  currentTunnelProc = null;
318
- log(chalk.yellow(` smee relay exited — reconnecting in ${smeeRetryDelay / 1000}s`));
512
+ board.setTunnel('smee', channelUrl, false);
513
+ cLog(chalk.yellow(`smee relay exited — reconnecting in ${smeeRetryDelay / 1000}s`));
319
514
  fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true, backend: 'smee' });
320
515
  await new Promise(r => setTimeout(r, smeeRetryDelay));
321
516
  smeeRetryDelay = Math.min(smeeRetryDelay * 2, 60_000);
517
+ board.setTunnel('smee', channelUrl, true);
322
518
  }
323
519
  return;
324
520
  }
325
521
  // ── localhost.run mode ────────────────────────────────────────────────────
326
522
  let reconnectDelay = 5_000;
327
523
  while (running) {
328
- log('Opening tunnel via localhost.run...');
524
+ board.setTunnel('localhost.run', null, false);
329
525
  let tunnelUrl;
330
526
  let tunnelProc;
331
527
  try {
@@ -336,7 +532,7 @@ export async function runWatch(configPath) {
336
532
  if (!running)
337
533
  break;
338
534
  const msg = err instanceof Error ? err.message : String(err);
339
- log(chalk.yellow(`tunnel failed: ${msg} — retrying in ${reconnectDelay / 1000}s`));
535
+ cLog(chalk.yellow(`tunnel failed: ${msg} — retrying in ${reconnectDelay / 1000}s`));
340
536
  fileLog({ level: 'warn', event: 'tunnel_error', message: msg });
341
537
  await new Promise(r => setTimeout(r, reconnectDelay));
342
538
  reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
@@ -344,63 +540,79 @@ export async function runWatch(configPath) {
344
540
  }
345
541
  reconnectDelay = 5_000; // reset backoff on success
346
542
  currentTunnelProc = tunnelProc;
347
- const webhookUrl = `${tunnelUrl}${webhookPath}`;
348
- log(chalk.green(` ✓ tunnel ready: ${chalk.cyan(tunnelUrl)}`));
349
- console.log(` tunnel ${chalk.cyan(tunnelUrl)}`);
350
- console.log(chalk.dim('Waiting for PR events — Ctrl+C to stop.\n'));
543
+ board.setTunnel('localhost.run', tunnelUrl, true);
544
+ cLog(`${chalk.green('')} tunnel ready: ${chalk.cyan(tunnelUrl)}`);
351
545
  fileLog({ level: 'info', event: 'tunnel_opened', url: tunnelUrl });
352
- // Register webhooks for this tunnel session
546
+ // Register webhooks in parallel: dedup check → register with backoff → aggregate summary
547
+ const webhookUrl = `${tunnelUrl}${webhookPath}`;
353
548
  currentRegistered = [];
354
- for (const scope of scopes) {
549
+ let hookOk = 0, hookFail = 0;
550
+ await Promise.all(scopes.map(async (scope) => {
355
551
  const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
552
+ // Dedup: skip if a hook for this exact URL already exists (e.g. previous session not cleaned up)
553
+ let existingId = null;
356
554
  try {
357
- if ('org' in scope) {
358
- const hookId = await registerOrgWebhook(scope.org, webhookUrl, webhookSecret, token);
359
- currentRegistered.push({ type: 'org', org: scope.org, hookId });
555
+ existingId = 'org' in scope
556
+ ? await findOrgWebhook(scope.org, webhookUrl, token)
557
+ : await findRepoWebhook(scope.owner, scope.repo, webhookUrl, token);
558
+ }
559
+ catch { /* ignore — proceed to register */ }
560
+ if (existingId !== null) {
561
+ currentRegistered.push('org' in scope
562
+ ? { type: 'org', org: scope.org, hookId: existingId }
563
+ : { type: 'repo', owner: scope.owner, repo: scope.repo, hookId: existingId });
564
+ hookOk++;
565
+ fileLog({ level: 'info', event: 'webhook_active', scope: label, url: webhookUrl });
566
+ return;
567
+ }
568
+ // Register with exponential back-off: delay 2s then 4s before giving up
569
+ let hookId = null;
570
+ let lastErr = '';
571
+ for (let attempt = 0; attempt < 3; attempt++) {
572
+ if (attempt > 0) {
573
+ const delay = 2 ** attempt * 1000;
574
+ fileLog({ level: 'warn', event: 'webhook_register_retry', scope: label, attempt, message: lastErr });
575
+ await new Promise(r => setTimeout(r, delay));
360
576
  }
361
- else {
362
- const hookId = await registerRepoWebhook(scope.owner, scope.repo, webhookUrl, webhookSecret, token);
363
- currentRegistered.push({ type: 'repo', owner: scope.owner, repo: scope.repo, hookId });
577
+ try {
578
+ hookId = 'org' in scope
579
+ ? await registerOrgWebhook(scope.org, webhookUrl, webhookSecret, token)
580
+ : await registerRepoWebhook(scope.owner, scope.repo, webhookUrl, webhookSecret, token);
581
+ break;
364
582
  }
365
- log(chalk.green(` ✓ webhook registered for ${label}`));
583
+ catch (err) {
584
+ lastErr = err instanceof Error ? err.message : String(err);
585
+ }
586
+ }
587
+ if (hookId !== null) {
588
+ currentRegistered.push('org' in scope
589
+ ? { type: 'org', org: scope.org, hookId }
590
+ : { type: 'repo', owner: scope.owner, repo: scope.repo, hookId });
591
+ hookOk++;
366
592
  fileLog({ level: 'info', event: 'webhook_registered', scope: label, url: webhookUrl });
367
593
  }
368
- catch (err) {
369
- const msg = err instanceof Error ? err.message : String(err);
370
- const isCreds = /bad credentials|\[401\]/i.test(msg);
371
- // 403 always means insufficient scope.
372
- // 404 on an org webhook means the token lacks admin:org_hook scope (GitHub
373
- // hides the endpoint rather than returning 403). For repo webhooks, 404
374
- // means the repo itself is not found — show the raw error instead.
375
- const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(msg)
376
- || ('org' in scope && /\[404\]/i.test(msg));
377
- log(chalk.yellow(` ⚠ could not register webhook for ${label}`));
594
+ else {
595
+ hookFail++;
596
+ const isCreds = /bad credentials|\[401\]/i.test(lastErr);
597
+ const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(lastErr)
598
+ || ('org' in scope && /\[404\]/i.test(lastErr));
378
599
  if (isCreds) {
379
- log(chalk.dim(` token invalid or expired`));
380
- log(` ${chalk.yellow('→')} ${chalk.cyan('gh auth refresh')}`);
381
- log(` ${chalk.yellow('→')} ${chalk.cyan('https://github.com/settings/tokens')} ${chalk.dim('(regenerate PAT)')}`);
600
+ bLog(` ${chalk.yellow('⚠')} webhook failed (${label}): token invalid run: ${chalk.cyan('gh auth refresh')}`);
382
601
  }
383
602
  else if (isScope) {
384
- log(chalk.dim(` token missing admin:org_hook scope or org Owner role`));
385
- log(` ${chalk.yellow('→')} ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
386
- log(` ${chalk.yellow('→')} ${chalk.cyan('https://github.com/settings/tokens')} ${chalk.dim('new PAT → enable admin:org scope')}`);
387
- log(` ${chalk.dim(' or register the webhook manually:')}`);
388
- log(` ${chalk.dim(' Payload URL')} ${chalk.cyan(webhookUrl)}`);
389
- log(` ${chalk.dim(' Secret')} ${chalk.cyan('cat ~/.crosscheck/webhook-secret')}`);
390
- log(` ${chalk.dim(' Hooks page')} ${chalk.cyan(`https://github.com/organizations/${label}/settings/hooks`)}`);
603
+ bLog(` ${chalk.yellow('⚠')} webhook failed (${label}): missing scope run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
391
604
  }
392
605
  else {
393
- log(chalk.dim(` ${msg}`));
394
- log(` ${chalk.dim(' Payload URL')} ${chalk.cyan(webhookUrl)}`);
395
- log(` ${chalk.dim(' Secret')} ${chalk.cyan('cat ~/.crosscheck/webhook-secret')}`);
606
+ bLog(` ${chalk.yellow('⚠')} webhook failed (${label}): ${lastErr}`);
396
607
  }
397
- const hooksUrl = 'org' in scope
398
- ? `https://github.com/organizations/${scope.org}/settings/hooks`
399
- : `https://github.com/${scope.owner}/${scope.repo}/settings/hooks`;
400
- log(chalk.dim(` to register manually: Payload URL = ${webhookUrl} Secret = (see ~/.crosscheck/webhook-secret)`));
401
- log(chalk.dim(` ${hooksUrl}`));
608
+ bLog(` manual Payload URL: ${chalk.cyan(webhookUrl)}`);
609
+ fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: lastErr });
402
610
  }
403
- }
611
+ }));
612
+ // Single aggregated connectivity line instead of one per repo
613
+ const hookTotal = scopes.length;
614
+ cLog(`${hookFail === 0 ? chalk.green('✓') : chalk.yellow('⚠')} webhooks registered: ${hookOk}/${hookTotal}${hookFail > 0 ? ` (${hookFail} failed)` : ''}`);
615
+ fileLog({ level: 'info', event: 'webhooks_registered', count: hookOk, total: hookTotal, failed: hookFail, url: webhookUrl });
404
616
  // Wait for this tunnel session to end.
405
617
  // Health check kills the SSH proc if lhr.life goes dead without exiting.
406
618
  await waitForTunnelEnd(tunnelProc, tunnelUrl);
@@ -408,7 +620,8 @@ export async function runWatch(configPath) {
408
620
  break;
409
621
  // Clean up webhooks tied to the old URL before reconnecting
410
622
  await deleteCurrentWebhooks();
411
- log(chalk.yellow(' tunnel disconnected — reconnecting in 5s...'));
623
+ board.setTunnel('localhost.run', tunnelUrl, false);
624
+ cLog(chalk.yellow('tunnel disconnected — reconnecting in 5s...'));
412
625
  fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true });
413
626
  await new Promise(r => setTimeout(r, reconnectDelay));
414
627
  }