@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.
- package/README.md +26 -9
- package/crosscheck.config.example.yml +98 -5
- 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__/loader.test.d.ts +2 -0
- package/dist/__tests__/loader.test.d.ts.map +1 -0
- package/dist/__tests__/loader.test.js +131 -0
- package/dist/__tests__/loader.test.js.map +1 -0
- package/dist/__tests__/optimize.test.js +16 -3
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/ck.d.ts +3 -0
- package/dist/ck.d.ts.map +1 -0
- package/dist/ck.js +8 -0
- package/dist/ck.js.map +1 -0
- package/dist/cli.js +12 -4
- 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 +63 -29
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +12 -6
- 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 +152 -34
- 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 +348 -135
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/loader.d.ts +10 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +196 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +461 -35
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +72 -5
- 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 +159 -2
- package/dist/github/client.js.map +1 -1
- package/dist/github/detector.d.ts +9 -2
- package/dist/github/detector.d.ts.map +1 -1
- package/dist/github/detector.js +86 -10
- 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 +54 -0
- package/dist/lib/board.d.ts.map +1 -0
- package/dist/lib/board.js +406 -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 +14 -14
- 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 +202 -23
- package/get-started.zh.md +2 -3
- package/package.json +4 -3
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 } 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(
|
|
85
|
-
const
|
|
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
|
-
|
|
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(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
|
-
|
|
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);
|
|
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
|
-
|
|
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,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
|
-
//
|
|
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) {
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
546
|
+
// Register webhooks in parallel: dedup check → register with backoff → aggregate summary
|
|
547
|
+
const webhookUrl = `${tunnelUrl}${webhookPath}`;
|
|
353
548
|
currentRegistered = [];
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
const isCreds = /bad credentials|\[401\]/i.test(
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
}
|