@motivation-labs/crosscheck 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -7
- package/crosscheck.config.example.yml +90 -4
- package/dist/__tests__/backtrace.test.d.ts +2 -0
- package/dist/__tests__/backtrace.test.d.ts.map +1 -0
- package/dist/__tests__/backtrace.test.js +158 -0
- package/dist/__tests__/backtrace.test.js.map +1 -0
- package/dist/__tests__/optimize.test.js +16 -3
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/diagnose.d.ts +1 -0
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +14 -0
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +55 -18
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +11 -5
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/serve.d.ts +7 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +109 -16
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +1 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/watch.d.ts +7 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +349 -143
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/loader.d.ts +8 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +143 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +445 -27
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +70 -7
- package/dist/config/schema.js.map +1 -1
- package/dist/github/client.d.ts +26 -0
- package/dist/github/client.d.ts.map +1 -1
- package/dist/github/client.js +153 -0
- package/dist/github/client.js.map +1 -1
- package/dist/github/detector.d.ts +8 -1
- package/dist/github/detector.d.ts.map +1 -1
- package/dist/github/detector.js +84 -12
- package/dist/github/detector.js.map +1 -1
- package/dist/lib/backtrace.d.ts +20 -0
- package/dist/lib/backtrace.d.ts.map +1 -0
- package/dist/lib/backtrace.js +75 -0
- package/dist/lib/backtrace.js.map +1 -0
- package/dist/lib/board.d.ts +52 -0
- package/dist/lib/board.d.ts.map +1 -0
- package/dist/lib/board.js +394 -0
- package/dist/lib/board.js.map +1 -0
- package/dist/lib/runner.d.ts +10 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +129 -51
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/verdict.d.ts +1 -0
- package/dist/lib/verdict.d.ts.map +1 -1
- package/dist/lib/verdict.js +27 -7
- package/dist/lib/verdict.js.map +1 -1
- package/dist/lib/workflow.d.ts +8 -8
- package/dist/lib/workflow.d.ts.map +1 -1
- package/dist/lib/workflow.js +22 -5
- package/dist/lib/workflow.js.map +1 -1
- package/dist/reviewers/claude.d.ts +1 -1
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +4 -6
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex.d.ts +2 -2
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +6 -6
- package/dist/reviewers/codex.js.map +1 -1
- package/dist/reviewers/fix.d.ts +5 -0
- package/dist/reviewers/fix.d.ts.map +1 -0
- package/dist/reviewers/fix.js +87 -0
- package/dist/reviewers/fix.js.map +1 -0
- package/get-started.md +190 -21
- package/package.json +1 -1
package/dist/commands/watch.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import { loadConfig, getGithubToken, getWebhookSecret, resolveConfigPath, detectGitHubLogin,
|
|
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 } 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,7 +103,8 @@ function openTunnel(localPort) {
|
|
|
81
103
|
});
|
|
82
104
|
});
|
|
83
105
|
}
|
|
84
|
-
export async function runWatch(
|
|
106
|
+
export async function runWatch(opts = {}) {
|
|
107
|
+
const configPath = opts.config;
|
|
85
108
|
let config = loadConfig(configPath);
|
|
86
109
|
initLogger(config.logs);
|
|
87
110
|
process.on('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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(new Date().toLocaleTimeString());
|
|
170
|
+
const tsIndent = ' '.repeat(new Date().toLocaleTimeString().length + 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(new Date().toLocaleTimeString())} ${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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
log(`${chalk.bold(`PR #${prNumber}`)} ${event.action}: ${chalk.dim(pr.title)}`);
|
|
141
|
-
const origin = detectPROrigin(pr.body ?? '', config, pr.user.login);
|
|
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(new Date().toLocaleTimeString()) + ' ' + 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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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,57 +366,126 @@ export async function runWatch(configPath) {
|
|
|
249
366
|
};
|
|
250
367
|
process.on('SIGINT', () => { void cleanup(); });
|
|
251
368
|
process.on('SIGTERM', () => { void cleanup(); });
|
|
252
|
-
//
|
|
369
|
+
// ── Static startup banner ─────────────────────────────────────────────────
|
|
253
370
|
console.log(chalk.dim(`\n "${randomFortune()}"\n`));
|
|
254
371
|
console.log(chalk.bold('crosscheck watch\n'));
|
|
255
|
-
if (
|
|
256
|
-
|
|
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
|
|
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
|
|
266
|
-
if (config.routing.allowed_authors.length === 0) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
console.log(` ${chalk.dim('Add to config:')} ${chalk.cyan('routing:\n allowed_authors:\n - your-github-login')}`);
|
|
277
|
-
}
|
|
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 {
|
|
405
|
+
console.log();
|
|
406
|
+
console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('No author filter set — all PRs in monitored orgs/repos will be reviewed.')}`);
|
|
407
|
+
console.log(` ${chalk.dim('Run')} ${chalk.cyan('crosscheck watch --reconfigure')} ${chalk.dim('to set up a deployment mode.')}`);
|
|
278
408
|
}
|
|
279
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
|
+
}
|
|
280
432
|
// ── Smee mode ─────────────────────────────────────────────────────────────
|
|
281
|
-
//
|
|
282
|
-
// 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.
|
|
283
434
|
if (config.tunnel.backend === 'smee') {
|
|
284
435
|
const channelUrl = config.tunnel.smee_channel;
|
|
285
436
|
if (!channelUrl) {
|
|
437
|
+
board.stop();
|
|
286
438
|
console.error(chalk.red('✗ tunnel.smee_channel is required when tunnel.backend: smee'));
|
|
287
439
|
console.error(chalk.dim(' Visit https://smee.io/new to get a free channel URL.'));
|
|
288
440
|
server.close(() => process.exit(1));
|
|
289
441
|
return;
|
|
290
442
|
}
|
|
291
|
-
|
|
292
|
-
console.log(chalk.dim(` Register this as GitHub webhook Payload URL, then:`));
|
|
293
|
-
console.log(chalk.dim(` webhook secret: cat ~/.crosscheck/webhook-secret`));
|
|
294
|
-
console.log(chalk.dim('Waiting for PR events — Ctrl+C to stop.\n'));
|
|
443
|
+
board.setTunnel('smee', channelUrl, true);
|
|
295
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
|
+
}
|
|
296
482
|
let smeeRetryDelay = 5_000;
|
|
297
483
|
while (running) {
|
|
298
484
|
const smeeProc = spawn('smee', [
|
|
299
485
|
'--url', channelUrl,
|
|
300
486
|
'--path', config.server.webhook_path,
|
|
301
487
|
'--port', String(config.server.port),
|
|
302
|
-
], { stdio: '
|
|
488
|
+
], { stdio: 'pipe' });
|
|
303
489
|
currentTunnelProc = smeeProc;
|
|
304
490
|
try {
|
|
305
491
|
await new Promise((resolve, reject) => {
|
|
@@ -315,6 +501,7 @@ export async function runWatch(configPath) {
|
|
|
315
501
|
});
|
|
316
502
|
}
|
|
317
503
|
catch (err) {
|
|
504
|
+
board.stop();
|
|
318
505
|
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
319
506
|
server.close(() => process.exit(1));
|
|
320
507
|
return;
|
|
@@ -322,17 +509,19 @@ export async function runWatch(configPath) {
|
|
|
322
509
|
if (!running)
|
|
323
510
|
break;
|
|
324
511
|
currentTunnelProc = null;
|
|
325
|
-
|
|
512
|
+
board.setTunnel('smee', channelUrl, false);
|
|
513
|
+
cLog(chalk.yellow(`smee relay exited — reconnecting in ${smeeRetryDelay / 1000}s`));
|
|
326
514
|
fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true, backend: 'smee' });
|
|
327
515
|
await new Promise(r => setTimeout(r, smeeRetryDelay));
|
|
328
516
|
smeeRetryDelay = Math.min(smeeRetryDelay * 2, 60_000);
|
|
517
|
+
board.setTunnel('smee', channelUrl, true);
|
|
329
518
|
}
|
|
330
519
|
return;
|
|
331
520
|
}
|
|
332
521
|
// ── localhost.run mode ────────────────────────────────────────────────────
|
|
333
522
|
let reconnectDelay = 5_000;
|
|
334
523
|
while (running) {
|
|
335
|
-
|
|
524
|
+
board.setTunnel('localhost.run', null, false);
|
|
336
525
|
let tunnelUrl;
|
|
337
526
|
let tunnelProc;
|
|
338
527
|
try {
|
|
@@ -343,7 +532,7 @@ export async function runWatch(configPath) {
|
|
|
343
532
|
if (!running)
|
|
344
533
|
break;
|
|
345
534
|
const msg = err instanceof Error ? err.message : String(err);
|
|
346
|
-
|
|
535
|
+
cLog(chalk.yellow(`tunnel failed: ${msg} — retrying in ${reconnectDelay / 1000}s`));
|
|
347
536
|
fileLog({ level: 'warn', event: 'tunnel_error', message: msg });
|
|
348
537
|
await new Promise(r => setTimeout(r, reconnectDelay));
|
|
349
538
|
reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
|
|
@@ -351,63 +540,79 @@ export async function runWatch(configPath) {
|
|
|
351
540
|
}
|
|
352
541
|
reconnectDelay = 5_000; // reset backoff on success
|
|
353
542
|
currentTunnelProc = tunnelProc;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
console.log(` tunnel ${chalk.cyan(tunnelUrl)}`);
|
|
357
|
-
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)}`);
|
|
358
545
|
fileLog({ level: 'info', event: 'tunnel_opened', url: tunnelUrl });
|
|
359
|
-
// Register webhooks
|
|
546
|
+
// Register webhooks in parallel: dedup check → register with backoff → aggregate summary
|
|
547
|
+
const webhookUrl = `${tunnelUrl}${webhookPath}`;
|
|
360
548
|
currentRegistered = [];
|
|
361
|
-
|
|
549
|
+
let hookOk = 0, hookFail = 0;
|
|
550
|
+
await Promise.all(scopes.map(async (scope) => {
|
|
362
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;
|
|
363
554
|
try {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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));
|
|
367
576
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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;
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
371
585
|
}
|
|
372
|
-
|
|
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++;
|
|
373
592
|
fileLog({ level: 'info', event: 'webhook_registered', scope: label, url: webhookUrl });
|
|
374
593
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const isCreds = /bad credentials|\[401\]/i.test(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// hides the endpoint rather than returning 403). For repo webhooks, 404
|
|
381
|
-
// means the repo itself is not found — show the raw error instead.
|
|
382
|
-
const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(msg)
|
|
383
|
-
|| ('org' in scope && /\[404\]/i.test(msg));
|
|
384
|
-
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));
|
|
385
599
|
if (isCreds) {
|
|
386
|
-
|
|
387
|
-
log(` ${chalk.yellow('→')} ${chalk.cyan('gh auth refresh')}`);
|
|
388
|
-
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')}`);
|
|
389
601
|
}
|
|
390
602
|
else if (isScope) {
|
|
391
|
-
|
|
392
|
-
log(` ${chalk.yellow('→')} ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
|
|
393
|
-
log(` ${chalk.yellow('→')} ${chalk.cyan('https://github.com/settings/tokens')} ${chalk.dim('new PAT → enable admin:org scope')}`);
|
|
394
|
-
log(` ${chalk.dim(' or register the webhook manually:')}`);
|
|
395
|
-
log(` ${chalk.dim(' Payload URL')} ${chalk.cyan(webhookUrl)}`);
|
|
396
|
-
log(` ${chalk.dim(' Secret')} ${chalk.cyan('cat ~/.crosscheck/webhook-secret')}`);
|
|
397
|
-
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')}`);
|
|
398
604
|
}
|
|
399
605
|
else {
|
|
400
|
-
|
|
401
|
-
log(` ${chalk.dim(' Payload URL')} ${chalk.cyan(webhookUrl)}`);
|
|
402
|
-
log(` ${chalk.dim(' Secret')} ${chalk.cyan('cat ~/.crosscheck/webhook-secret')}`);
|
|
606
|
+
bLog(` ${chalk.yellow('⚠')} webhook failed (${label}): ${lastErr}`);
|
|
403
607
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
: `https://github.com/${scope.owner}/${scope.repo}/settings/hooks`;
|
|
407
|
-
log(chalk.dim(` to register manually: Payload URL = ${webhookUrl} Secret = (see ~/.crosscheck/webhook-secret)`));
|
|
408
|
-
log(chalk.dim(` ${hooksUrl}`));
|
|
608
|
+
bLog(` manual Payload URL: ${chalk.cyan(webhookUrl)}`);
|
|
609
|
+
fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: lastErr });
|
|
409
610
|
}
|
|
410
|
-
}
|
|
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 });
|
|
411
616
|
// Wait for this tunnel session to end.
|
|
412
617
|
// Health check kills the SSH proc if lhr.life goes dead without exiting.
|
|
413
618
|
await waitForTunnelEnd(tunnelProc, tunnelUrl);
|
|
@@ -415,7 +620,8 @@ export async function runWatch(configPath) {
|
|
|
415
620
|
break;
|
|
416
621
|
// Clean up webhooks tied to the old URL before reconnecting
|
|
417
622
|
await deleteCurrentWebhooks();
|
|
418
|
-
|
|
623
|
+
board.setTunnel('localhost.run', tunnelUrl, false);
|
|
624
|
+
cLog(chalk.yellow('tunnel disconnected — reconnecting in 5s...'));
|
|
419
625
|
fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true });
|
|
420
626
|
await new Promise(r => setTimeout(r, reconnectDelay));
|
|
421
627
|
}
|