@humanbased/crosscheck 0.14.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/AGENT.md +207 -0
- package/ISSUE.md +234 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/README.zh.md +169 -0
- package/assets/logo.png +0 -0
- package/assets/screenshot-watch-timing.png +0 -0
- package/assets/screenshot-watch-timing.svg +1 -0
- package/assets/screenshot-watch.png +0 -0
- package/crosscheck.config.example.yml +214 -0
- package/dist/__tests__/annotation.test.d.ts +2 -0
- package/dist/__tests__/annotation.test.d.ts.map +1 -0
- package/dist/__tests__/annotation.test.js +134 -0
- package/dist/__tests__/annotation.test.js.map +1 -0
- 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 +280 -0
- package/dist/__tests__/backtrace.test.js.map +1 -0
- package/dist/__tests__/board.test.d.ts +2 -0
- package/dist/__tests__/board.test.d.ts.map +1 -0
- package/dist/__tests__/board.test.js +149 -0
- package/dist/__tests__/board.test.js.map +1 -0
- package/dist/__tests__/codex.test.d.ts +2 -0
- package/dist/__tests__/codex.test.d.ts.map +1 -0
- package/dist/__tests__/codex.test.js +92 -0
- package/dist/__tests__/codex.test.js.map +1 -0
- package/dist/__tests__/comment-bodies.test.d.ts +2 -0
- package/dist/__tests__/comment-bodies.test.d.ts.map +1 -0
- package/dist/__tests__/comment-bodies.test.js +75 -0
- package/dist/__tests__/comment-bodies.test.js.map +1 -0
- package/dist/__tests__/conflict-resolve.test.d.ts +2 -0
- package/dist/__tests__/conflict-resolve.test.d.ts.map +1 -0
- package/dist/__tests__/conflict-resolve.test.js +123 -0
- package/dist/__tests__/conflict-resolve.test.js.map +1 -0
- package/dist/__tests__/crosscheck-commit.test.d.ts +2 -0
- package/dist/__tests__/crosscheck-commit.test.d.ts.map +1 -0
- package/dist/__tests__/crosscheck-commit.test.js +13 -0
- package/dist/__tests__/crosscheck-commit.test.js.map +1 -0
- package/dist/__tests__/detector.test.d.ts +2 -0
- package/dist/__tests__/detector.test.d.ts.map +1 -0
- package/dist/__tests__/detector.test.js +112 -0
- package/dist/__tests__/detector.test.js.map +1 -0
- package/dist/__tests__/diagnose.test.d.ts +2 -0
- package/dist/__tests__/diagnose.test.d.ts.map +1 -0
- package/dist/__tests__/diagnose.test.js +164 -0
- package/dist/__tests__/diagnose.test.js.map +1 -0
- package/dist/__tests__/diff-hash.test.d.ts +2 -0
- package/dist/__tests__/diff-hash.test.d.ts.map +1 -0
- package/dist/__tests__/diff-hash.test.js +126 -0
- package/dist/__tests__/diff-hash.test.js.map +1 -0
- package/dist/__tests__/durations.test.d.ts +2 -0
- package/dist/__tests__/durations.test.d.ts.map +1 -0
- package/dist/__tests__/durations.test.js +26 -0
- package/dist/__tests__/durations.test.js.map +1 -0
- package/dist/__tests__/event-fields.test.d.ts +2 -0
- package/dist/__tests__/event-fields.test.d.ts.map +1 -0
- package/dist/__tests__/event-fields.test.js +50 -0
- package/dist/__tests__/event-fields.test.js.map +1 -0
- package/dist/__tests__/filter.test.d.ts +2 -0
- package/dist/__tests__/filter.test.d.ts.map +1 -0
- package/dist/__tests__/filter.test.js +21 -0
- package/dist/__tests__/filter.test.js.map +1 -0
- package/dist/__tests__/fix.test.d.ts +2 -0
- package/dist/__tests__/fix.test.d.ts.map +1 -0
- package/dist/__tests__/fix.test.js +124 -0
- package/dist/__tests__/fix.test.js.map +1 -0
- package/dist/__tests__/github-client.test.d.ts +2 -0
- package/dist/__tests__/github-client.test.d.ts.map +1 -0
- package/dist/__tests__/github-client.test.js +22 -0
- package/dist/__tests__/github-client.test.js.map +1 -0
- package/dist/__tests__/github-scan-client.test.d.ts +2 -0
- package/dist/__tests__/github-scan-client.test.d.ts.map +1 -0
- package/dist/__tests__/github-scan-client.test.js +100 -0
- package/dist/__tests__/github-scan-client.test.js.map +1 -0
- package/dist/__tests__/is-fresh-review-comment.test.d.ts +2 -0
- package/dist/__tests__/is-fresh-review-comment.test.d.ts.map +1 -0
- package/dist/__tests__/is-fresh-review-comment.test.js +86 -0
- package/dist/__tests__/is-fresh-review-comment.test.js.map +1 -0
- package/dist/__tests__/issue.test.d.ts +2 -0
- package/dist/__tests__/issue.test.d.ts.map +1 -0
- package/dist/__tests__/issue.test.js +259 -0
- package/dist/__tests__/issue.test.js.map +1 -0
- package/dist/__tests__/kickass.test.d.ts +2 -0
- package/dist/__tests__/kickass.test.d.ts.map +1 -0
- package/dist/__tests__/kickass.test.js +268 -0
- package/dist/__tests__/kickass.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 +180 -0
- package/dist/__tests__/loader.test.js.map +1 -0
- package/dist/__tests__/onboard-preservation.test.d.ts +2 -0
- package/dist/__tests__/onboard-preservation.test.d.ts.map +1 -0
- package/dist/__tests__/onboard-preservation.test.js +506 -0
- package/dist/__tests__/onboard-preservation.test.js.map +1 -0
- package/dist/__tests__/optimize.test.d.ts +2 -0
- package/dist/__tests__/optimize.test.d.ts.map +1 -0
- package/dist/__tests__/optimize.test.js +101 -0
- package/dist/__tests__/optimize.test.js.map +1 -0
- package/dist/__tests__/post-review-comment.test.d.ts +2 -0
- package/dist/__tests__/post-review-comment.test.d.ts.map +1 -0
- package/dist/__tests__/post-review-comment.test.js +44 -0
- package/dist/__tests__/post-review-comment.test.js.map +1 -0
- package/dist/__tests__/pr-lock.test.d.ts +2 -0
- package/dist/__tests__/pr-lock.test.d.ts.map +1 -0
- package/dist/__tests__/pr-lock.test.js +115 -0
- package/dist/__tests__/pr-lock.test.js.map +1 -0
- package/dist/__tests__/pr-picker.test.d.ts +2 -0
- package/dist/__tests__/pr-picker.test.d.ts.map +1 -0
- package/dist/__tests__/pr-picker.test.js +57 -0
- package/dist/__tests__/pr-picker.test.js.map +1 -0
- package/dist/__tests__/pr-status-scan.test.d.ts +2 -0
- package/dist/__tests__/pr-status-scan.test.d.ts.map +1 -0
- package/dist/__tests__/pr-status-scan.test.js +92 -0
- package/dist/__tests__/pr-status-scan.test.js.map +1 -0
- package/dist/__tests__/pr-status.test.d.ts +2 -0
- package/dist/__tests__/pr-status.test.d.ts.map +1 -0
- package/dist/__tests__/pr-status.test.js +346 -0
- package/dist/__tests__/pr-status.test.js.map +1 -0
- package/dist/__tests__/repo-picker.test.d.ts +2 -0
- package/dist/__tests__/repo-picker.test.d.ts.map +1 -0
- package/dist/__tests__/repo-picker.test.js +115 -0
- package/dist/__tests__/repo-picker.test.js.map +1 -0
- package/dist/__tests__/review-comment-body.test.d.ts +2 -0
- package/dist/__tests__/review-comment-body.test.d.ts.map +1 -0
- package/dist/__tests__/review-comment-body.test.js +54 -0
- package/dist/__tests__/review-comment-body.test.js.map +1 -0
- package/dist/__tests__/review-models.test.d.ts +2 -0
- package/dist/__tests__/review-models.test.d.ts.map +1 -0
- package/dist/__tests__/review-models.test.js +39 -0
- package/dist/__tests__/review-models.test.js.map +1 -0
- package/dist/__tests__/review-status.test.d.ts +2 -0
- package/dist/__tests__/review-status.test.d.ts.map +1 -0
- package/dist/__tests__/review-status.test.js +95 -0
- package/dist/__tests__/review-status.test.js.map +1 -0
- package/dist/__tests__/runner.test.d.ts +2 -0
- package/dist/__tests__/runner.test.d.ts.map +1 -0
- package/dist/__tests__/runner.test.js +204 -0
- package/dist/__tests__/runner.test.js.map +1 -0
- package/dist/__tests__/scan-cache.test.d.ts +2 -0
- package/dist/__tests__/scan-cache.test.d.ts.map +1 -0
- package/dist/__tests__/scan-cache.test.js +59 -0
- package/dist/__tests__/scan-cache.test.js.map +1 -0
- package/dist/__tests__/scan-client.test.d.ts +2 -0
- package/dist/__tests__/scan-client.test.d.ts.map +1 -0
- package/dist/__tests__/scan-client.test.js +30 -0
- package/dist/__tests__/scan-client.test.js.map +1 -0
- package/dist/__tests__/scan.test.d.ts +2 -0
- package/dist/__tests__/scan.test.d.ts.map +1 -0
- package/dist/__tests__/scan.test.js +115 -0
- package/dist/__tests__/scan.test.js.map +1 -0
- package/dist/__tests__/scopes.test.d.ts +2 -0
- package/dist/__tests__/scopes.test.d.ts.map +1 -0
- package/dist/__tests__/scopes.test.js +101 -0
- package/dist/__tests__/scopes.test.js.map +1 -0
- package/dist/__tests__/sha-cache.test.d.ts +2 -0
- package/dist/__tests__/sha-cache.test.d.ts.map +1 -0
- package/dist/__tests__/sha-cache.test.js +40 -0
- package/dist/__tests__/sha-cache.test.js.map +1 -0
- package/dist/__tests__/smart-switch.test.d.ts +2 -0
- package/dist/__tests__/smart-switch.test.d.ts.map +1 -0
- package/dist/__tests__/smart-switch.test.js +145 -0
- package/dist/__tests__/smart-switch.test.js.map +1 -0
- 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.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +132 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/diagnose.d.ts +54 -0
- package/dist/commands/diagnose.d.ts.map +1 -0
- package/dist/commands/diagnose.js +294 -0
- package/dist/commands/diagnose.js.map +1 -0
- package/dist/commands/impact.d.ts +38 -0
- package/dist/commands/impact.d.ts.map +1 -0
- package/dist/commands/impact.js +210 -0
- package/dist/commands/impact.js.map +1 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +183 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/issue.d.ts +25 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +445 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/kickass.d.ts +59 -0
- package/dist/commands/kickass.d.ts.map +1 -0
- package/dist/commands/kickass.js +288 -0
- package/dist/commands/kickass.js.map +1 -0
- package/dist/commands/onboard.d.ts +70 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +883 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/optimize.d.ts +16 -0
- package/dist/commands/optimize.d.ts.map +1 -0
- package/dist/commands/optimize.js +244 -0
- package/dist/commands/optimize.js.map +1 -0
- package/dist/commands/review.d.ts +2 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/review.js +118 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/run.d.ts +13 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +243 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/scan.d.ts +94 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +276 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/serve.d.ts +9 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +402 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +89 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/watch.d.ts +9 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +902 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/config/loader.d.ts +47 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +334 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +814 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +152 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/github/client.d.ts +139 -0
- package/dist/github/client.d.ts.map +1 -0
- package/dist/github/client.js +711 -0
- package/dist/github/client.js.map +1 -0
- package/dist/github/detector.d.ts +12 -0
- package/dist/github/detector.d.ts.map +1 -0
- package/dist/github/detector.js +120 -0
- package/dist/github/detector.js.map +1 -0
- package/dist/github/merge.d.ts +9 -0
- package/dist/github/merge.d.ts.map +1 -0
- package/dist/github/merge.js +33 -0
- package/dist/github/merge.js.map +1 -0
- package/dist/github/review-status.d.ts +6 -0
- package/dist/github/review-status.d.ts.map +1 -0
- package/dist/github/review-status.js +51 -0
- package/dist/github/review-status.js.map +1 -0
- package/dist/github/webhook.d.ts +41 -0
- package/dist/github/webhook.d.ts.map +1 -0
- package/dist/github/webhook.js +50 -0
- package/dist/github/webhook.js.map +1 -0
- package/dist/lib/annotation.d.ts +23 -0
- package/dist/lib/annotation.d.ts.map +1 -0
- package/dist/lib/annotation.js +103 -0
- package/dist/lib/annotation.js.map +1 -0
- package/dist/lib/backtrace.d.ts +40 -0
- package/dist/lib/backtrace.d.ts.map +1 -0
- package/dist/lib/backtrace.js +169 -0
- package/dist/lib/backtrace.js.map +1 -0
- package/dist/lib/board.d.ts +74 -0
- package/dist/lib/board.d.ts.map +1 -0
- package/dist/lib/board.js +640 -0
- package/dist/lib/board.js.map +1 -0
- package/dist/lib/clone.d.ts +12 -0
- package/dist/lib/clone.d.ts.map +1 -0
- package/dist/lib/clone.js +30 -0
- package/dist/lib/clone.js.map +1 -0
- package/dist/lib/comment-bodies.d.ts +17 -0
- package/dist/lib/comment-bodies.d.ts.map +1 -0
- package/dist/lib/comment-bodies.js +51 -0
- package/dist/lib/comment-bodies.js.map +1 -0
- package/dist/lib/crosscheck-commit.d.ts +2 -0
- package/dist/lib/crosscheck-commit.d.ts.map +1 -0
- package/dist/lib/crosscheck-commit.js +4 -0
- package/dist/lib/crosscheck-commit.js.map +1 -0
- package/dist/lib/diff-hash.d.ts +16 -0
- package/dist/lib/diff-hash.d.ts.map +1 -0
- package/dist/lib/diff-hash.js +71 -0
- package/dist/lib/diff-hash.js.map +1 -0
- package/dist/lib/durations.d.ts +5 -0
- package/dist/lib/durations.d.ts.map +1 -0
- package/dist/lib/durations.js +39 -0
- package/dist/lib/durations.js.map +1 -0
- package/dist/lib/event-fields.d.ts +6 -0
- package/dist/lib/event-fields.d.ts.map +1 -0
- package/dist/lib/event-fields.js +20 -0
- package/dist/lib/event-fields.js.map +1 -0
- package/dist/lib/filter.d.ts +2 -0
- package/dist/lib/filter.d.ts.map +1 -0
- package/dist/lib/filter.js +4 -0
- package/dist/lib/filter.js.map +1 -0
- package/dist/lib/fortune.d.ts +2 -0
- package/dist/lib/fortune.d.ts.map +1 -0
- package/dist/lib/fortune.js +26 -0
- package/dist/lib/fortune.js.map +1 -0
- package/dist/lib/languages.d.ts +3 -0
- package/dist/lib/languages.d.ts.map +1 -0
- package/dist/lib/languages.js +26 -0
- package/dist/lib/languages.js.map +1 -0
- package/dist/lib/log-analysis.d.ts +17 -0
- package/dist/lib/log-analysis.d.ts.map +1 -0
- package/dist/lib/log-analysis.js +72 -0
- package/dist/lib/log-analysis.js.map +1 -0
- package/dist/lib/logger.d.ts +14 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +84 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/port.d.ts +2 -0
- package/dist/lib/port.d.ts.map +1 -0
- package/dist/lib/port.js +21 -0
- package/dist/lib/port.js.map +1 -0
- package/dist/lib/pr-lock.d.ts +4 -0
- package/dist/lib/pr-lock.d.ts.map +1 -0
- package/dist/lib/pr-lock.js +91 -0
- package/dist/lib/pr-lock.js.map +1 -0
- package/dist/lib/pr-picker.d.ts +10 -0
- package/dist/lib/pr-picker.d.ts.map +1 -0
- package/dist/lib/pr-picker.js +80 -0
- package/dist/lib/pr-picker.js.map +1 -0
- package/dist/lib/pr-status.d.ts +206 -0
- package/dist/lib/pr-status.d.ts.map +1 -0
- package/dist/lib/pr-status.js +613 -0
- package/dist/lib/pr-status.js.map +1 -0
- package/dist/lib/repo-picker.d.ts +23 -0
- package/dist/lib/repo-picker.d.ts.map +1 -0
- package/dist/lib/repo-picker.js +411 -0
- package/dist/lib/repo-picker.js.map +1 -0
- package/dist/lib/review-models.d.ts +7 -0
- package/dist/lib/review-models.d.ts.map +1 -0
- package/dist/lib/review-models.js +32 -0
- package/dist/lib/review-models.js.map +1 -0
- package/dist/lib/runner.d.ts +65 -0
- package/dist/lib/runner.d.ts.map +1 -0
- package/dist/lib/runner.js +710 -0
- package/dist/lib/runner.js.map +1 -0
- package/dist/lib/scan-cache.d.ts +31 -0
- package/dist/lib/scan-cache.d.ts.map +1 -0
- package/dist/lib/scan-cache.js +112 -0
- package/dist/lib/scan-cache.js.map +1 -0
- package/dist/lib/scopes.d.ts +16 -0
- package/dist/lib/scopes.d.ts.map +1 -0
- package/dist/lib/scopes.js +37 -0
- package/dist/lib/scopes.js.map +1 -0
- package/dist/lib/sha-cache.d.ts +7 -0
- package/dist/lib/sha-cache.d.ts.map +1 -0
- package/dist/lib/sha-cache.js +44 -0
- package/dist/lib/sha-cache.js.map +1 -0
- package/dist/lib/smart-switch.d.ts +44 -0
- package/dist/lib/smart-switch.d.ts.map +1 -0
- package/dist/lib/smart-switch.js +145 -0
- package/dist/lib/smart-switch.js.map +1 -0
- package/dist/lib/verdict.d.ts +9 -0
- package/dist/lib/verdict.d.ts.map +1 -0
- package/dist/lib/verdict.js +52 -0
- package/dist/lib/verdict.js.map +1 -0
- package/dist/lib/workflow.d.ts +85 -0
- package/dist/lib/workflow.d.ts.map +1 -0
- package/dist/lib/workflow.js +116 -0
- package/dist/lib/workflow.js.map +1 -0
- package/dist/reviewers/address.d.ts +5 -0
- package/dist/reviewers/address.d.ts.map +1 -0
- package/dist/reviewers/address.js +87 -0
- package/dist/reviewers/address.js.map +1 -0
- package/dist/reviewers/claude.d.ts +12 -0
- package/dist/reviewers/claude.d.ts.map +1 -0
- package/dist/reviewers/claude.js +78 -0
- package/dist/reviewers/claude.js.map +1 -0
- package/dist/reviewers/codex.d.ts +9 -0
- package/dist/reviewers/codex.d.ts.map +1 -0
- package/dist/reviewers/codex.js +121 -0
- package/dist/reviewers/codex.js.map +1 -0
- package/dist/reviewers/conflict-resolve.d.ts +15 -0
- package/dist/reviewers/conflict-resolve.d.ts.map +1 -0
- package/dist/reviewers/conflict-resolve.js +219 -0
- package/dist/reviewers/conflict-resolve.js.map +1 -0
- package/dist/reviewers/fix.d.ts +7 -0
- package/dist/reviewers/fix.d.ts.map +1 -0
- package/dist/reviewers/fix.js +197 -0
- package/dist/reviewers/fix.js.map +1 -0
- package/get-started.md +1271 -0
- package/get-started.zh.md +1208 -0
- package/package.json +75 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createWebhookServer } from '../github/webhook.js';
|
|
4
|
+
import { createGithubClient, getCommitMessage, 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';
|
|
7
|
+
import { randomFortune } from '../lib/fortune.js';
|
|
8
|
+
import { scanUnreviewedPRs } from '../lib/backtrace.js';
|
|
9
|
+
import { initLogger, log as fileLog, logError, logUncaught } from '../lib/logger.js';
|
|
10
|
+
import { isAuthorAllowed } from '../lib/filter.js';
|
|
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';
|
|
14
|
+
import { clonePRForReview } from '../lib/clone.js';
|
|
15
|
+
import { getSmartSwitch, isSubscriptionLimitError, detectFailedVendor, triggerSwitch, notifyReviewSuccess, stopSmartSwitch, } from '../lib/smart-switch.js';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { PersistentShaSet } from '../lib/sha-cache.js';
|
|
20
|
+
import { PersistentDiffHashMap, computeDiffHash } from '../lib/diff-hash.js';
|
|
21
|
+
import { dedupScopes } from '../lib/scopes.js';
|
|
22
|
+
import { acquirePRLock, releasePRLock } from '../lib/pr-lock.js';
|
|
23
|
+
import { checkRemoteLock, acquireRemoteLock, releaseRemoteLock, startRemoteLockHeartbeat } from '../github/review-status.js';
|
|
24
|
+
import { isCrosscheckCommitMessage } from '../lib/crosscheck-commit.js';
|
|
25
|
+
function buildFallbackConfig(config, fallbackVendor) {
|
|
26
|
+
return {
|
|
27
|
+
...config,
|
|
28
|
+
mode: 'single-vendor',
|
|
29
|
+
vendors: {
|
|
30
|
+
codex: { ...config.vendors.codex, enabled: fallbackVendor === 'codex' },
|
|
31
|
+
claude: { ...config.vendors.claude, enabled: fallbackVendor === 'claude' },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Compute PR diff size in lines, excluding noise (lockfiles, binaries, data files)
|
|
36
|
+
const NOISE_EXT = /\.(lock|snap|min\.js|min\.css|csv|json|png|jpg|jpeg|gif|svg|mp4|woff2?|ttf|eot|ico|pdf)$/i;
|
|
37
|
+
function computePRLoc(tmpDir, baseBranch) {
|
|
38
|
+
try {
|
|
39
|
+
const stat = execSync(`git diff --stat origin/${baseBranch}...HEAD`, { cwd: tmpDir, encoding: 'utf8' });
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (const line of stat.split('\n')) {
|
|
42
|
+
const m = line.match(/^\s+(.+?)\s+\|\s+(\d+)/);
|
|
43
|
+
if (!m)
|
|
44
|
+
continue;
|
|
45
|
+
const file = m[1].trim().replace(/\{.*?=> /, '').replace('}', ''); // handle rename notation
|
|
46
|
+
if (!NOISE_EXT.test(file))
|
|
47
|
+
total += parseInt(m[2], 10);
|
|
48
|
+
}
|
|
49
|
+
return total;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function detectCurrentRepo() {
|
|
56
|
+
try {
|
|
57
|
+
const remote = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
58
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
59
|
+
if (m)
|
|
60
|
+
return { owner: m[1], repo: m[2] };
|
|
61
|
+
}
|
|
62
|
+
catch { /* ignore */ }
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// lhr.life tunnels can go dead (503) without the SSH process exiting.
|
|
66
|
+
// Polls every 60s and kills the proc after 2 consecutive failures (~2 min detection).
|
|
67
|
+
function waitForTunnelEnd(tunnelProc, tunnelUrl) {
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
let failCount = 0;
|
|
70
|
+
const check = setInterval(async () => {
|
|
71
|
+
let alive = false;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(tunnelUrl, { signal: AbortSignal.timeout(8000) });
|
|
74
|
+
alive = res.status !== 503;
|
|
75
|
+
}
|
|
76
|
+
catch { /* network error = dead */ }
|
|
77
|
+
if (!alive) {
|
|
78
|
+
if (++failCount >= 2) {
|
|
79
|
+
clearInterval(check);
|
|
80
|
+
tunnelProc.kill();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
failCount = 0;
|
|
85
|
+
}
|
|
86
|
+
}, 60_000);
|
|
87
|
+
tunnelProc.on('exit', () => { clearInterval(check); resolve(); });
|
|
88
|
+
tunnelProc.on('error', () => { clearInterval(check); resolve(); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Opens a localhost.run SSH tunnel. Resolves with the public base URL once
|
|
92
|
+
// the tunnel is ready. Rejects after 20s if no URL appears in the output.
|
|
93
|
+
function openTunnel(localPort) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const proc = spawn('ssh', [
|
|
96
|
+
'-R', `80:localhost:${localPort}`,
|
|
97
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
98
|
+
'-o', 'ServerAliveInterval=30',
|
|
99
|
+
'-o', 'LogLevel=ERROR',
|
|
100
|
+
'nokey@localhost.run',
|
|
101
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
proc.kill();
|
|
104
|
+
reject(new Error('Tunnel did not start within 20s — check your internet connection'));
|
|
105
|
+
}, 20000);
|
|
106
|
+
const onData = (data) => {
|
|
107
|
+
const text = data.toString();
|
|
108
|
+
const match = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(?:localhost\.run|lhr\.life)[^\s]*/i);
|
|
109
|
+
if (match) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolve({ url: match[0].replace(/\/$/, ''), proc });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
proc.stdout?.on('data', onData);
|
|
115
|
+
proc.stderr?.on('data', onData);
|
|
116
|
+
proc.on('exit', (code) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
if (code !== 0 && code !== null) {
|
|
119
|
+
reject(new Error(`SSH tunnel exited (code ${code})`));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
export async function runWatch(opts = {}) {
|
|
125
|
+
const configPath = opts.config;
|
|
126
|
+
let config = loadConfig(configPath);
|
|
127
|
+
initLogger(config.logs);
|
|
128
|
+
process.on('uncaughtException', (err) => {
|
|
129
|
+
logUncaught('uncaughtException', err);
|
|
130
|
+
console.error(chalk.red(`\n✗ Uncaught exception: ${err.message}`));
|
|
131
|
+
process.exit(2);
|
|
132
|
+
});
|
|
133
|
+
process.on('unhandledRejection', (reason) => {
|
|
134
|
+
logUncaught('unhandledRejection', reason);
|
|
135
|
+
console.error(chalk.red(`\n✗ Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`));
|
|
136
|
+
process.exit(2);
|
|
137
|
+
});
|
|
138
|
+
let token;
|
|
139
|
+
try {
|
|
140
|
+
token = getGithubToken();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logError({ command: 'watch', phase: 'auth' }, err);
|
|
144
|
+
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
fileLog({ level: 'info', event: 'session_start', command: 'watch' });
|
|
148
|
+
const webhookSecret = getWebhookSecret();
|
|
149
|
+
const webhookPath = config.server.webhook_path;
|
|
150
|
+
// Board manages all terminal output after startup
|
|
151
|
+
const board = new PRBoard();
|
|
152
|
+
const workflow = loadWorkflow(process.cwd());
|
|
153
|
+
board.setConfig(config, workflow);
|
|
154
|
+
// Thin wrapper: routes important messages to both terminal and file log
|
|
155
|
+
const bLog = (line1, line2) => {
|
|
156
|
+
board.log(line1, line2);
|
|
157
|
+
fileLog({ level: 'info', event: 'message', message: line2 ? `${line1} ${line2}` : line1 });
|
|
158
|
+
};
|
|
159
|
+
// Connectivity events (tunnel/webhook) go into the live connectivity section
|
|
160
|
+
const cLog = (line) => {
|
|
161
|
+
board.logConnectivity(line);
|
|
162
|
+
fileLog({ level: 'info', event: 'message', message: line });
|
|
163
|
+
};
|
|
164
|
+
// PR deduplication — skip if already reviewing this PR+SHA
|
|
165
|
+
const inFlight = new Set();
|
|
166
|
+
// SHAs pushed by the fix step — persisted to disk so restarts don't re-review our own commits
|
|
167
|
+
const crosscheckShas = new PersistentShaSet();
|
|
168
|
+
// Last-reviewed diff hash per PR — skip reviews when a new SHA has identical diff vs base
|
|
169
|
+
// (force-push, amend, no-op rebase). Persisted so restarts don't re-review unchanged content.
|
|
170
|
+
const diffHashes = new PersistentDiffHashMap();
|
|
171
|
+
// PRs reviewed at least once this session — synchronize events on these run as recheck rounds
|
|
172
|
+
const reviewedPRKeys = new Set();
|
|
173
|
+
const prRoundCounts = new Map();
|
|
174
|
+
async function reviewPR(params) {
|
|
175
|
+
const { owner, repoName, prNumber } = params;
|
|
176
|
+
const key = `${owner}/${repoName}#${prNumber}@${params.headSha}`;
|
|
177
|
+
if (inFlight.has(key))
|
|
178
|
+
return;
|
|
179
|
+
inFlight.add(key);
|
|
180
|
+
// Outer try/finally ensures the inFlight key is always released, even if
|
|
181
|
+
// detectOriginFull / assignReviewer throw before the inner try block starts.
|
|
182
|
+
try {
|
|
183
|
+
if (!isAuthorAllowed(config.routing.allowed_authors, params.author)) {
|
|
184
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'author_not_allowed', author: params.author });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { origin, method: originMethod } = await detectOriginFull(params.body ?? '', params.headRef, owner, repoName, prNumber, config, token, params.author);
|
|
188
|
+
// Smart-switch: when cross-vendor is degraded, override to single-vendor with the healthy vendor
|
|
189
|
+
const ss = getSmartSwitch();
|
|
190
|
+
const effectiveConfig = (config.mode === 'cross-vendor' && ss.active && ss.fallbackVendor)
|
|
191
|
+
? buildFallbackConfig(config, ss.fallbackVendor)
|
|
192
|
+
: config;
|
|
193
|
+
const reviewer = await assignReviewer(origin, effectiveConfig);
|
|
194
|
+
fileLog({ level: 'info', event: 'pr_received', repo: `${owner}/${repoName}`, pr: prNumber, sha: params.headSha, action: params.action, origin, origin_method: originMethod, author: params.author, smart_switch_active: ss.active });
|
|
195
|
+
if (!reviewer) {
|
|
196
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'no_reviewer', origin });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const ts = chalk.dim(fmtTime());
|
|
200
|
+
const tsIndent = ' '.repeat(FMT_TIME_WIDTH + 2);
|
|
201
|
+
const modeNote = ss.active ? chalk.yellow(' [smart-switch]') : '';
|
|
202
|
+
bLog(`${ts} PR #${prNumber} ${params.action} ${chalk.dim(params.title)}`, `${tsIndent}origin=${chalk.yellow(origin)} via=${chalk.dim(originMethod)} reviewer=${chalk.cyan(reviewer)}${modeNote}`);
|
|
203
|
+
const pr = {
|
|
204
|
+
title: params.title,
|
|
205
|
+
body: params.body ?? '',
|
|
206
|
+
head: { ref: params.headRef, sha: params.headSha, repo: params.headRepo ? { full_name: params.headRepo } : null },
|
|
207
|
+
base: { ref: params.baseRef, repo: { full_name: `${owner}/${repoName}` } },
|
|
208
|
+
html_url: `https://github.com/${owner}/${repoName}/pull/${prNumber}`,
|
|
209
|
+
user: { login: params.author },
|
|
210
|
+
};
|
|
211
|
+
if (!acquirePRLock(owner, repoName, prNumber, params.headSha)) {
|
|
212
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_local' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const lockOctokit = createGithubClient(token);
|
|
216
|
+
if (await checkRemoteLock(lockOctokit, owner, repoName, params.headSha)) {
|
|
217
|
+
releasePRLock(owner, repoName, prNumber, params.headSha);
|
|
218
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_remote' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await acquireRemoteLock(lockOctokit, owner, repoName, params.headSha);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
releasePRLock(owner, repoName, prNumber, params.headSha);
|
|
226
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'lock' }, err);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const prKey = `${owner}/${repoName}#${prNumber}`;
|
|
230
|
+
const isRecheckRun = reviewedPRKeys.has(prKey);
|
|
231
|
+
const round = isRecheckRun ? (prRoundCounts.get(prKey) ?? 1) + 1 : 1;
|
|
232
|
+
const reviewStart = Date.now();
|
|
233
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'crosscheck-repo-'));
|
|
234
|
+
let stopHeartbeat = () => { };
|
|
235
|
+
let boardAdded = false;
|
|
236
|
+
try {
|
|
237
|
+
clonePRForReview({
|
|
238
|
+
owner, repo: repoName, prNumber, baseRef: params.baseRef,
|
|
239
|
+
tmpDir, token, protocol: config.clone_protocol,
|
|
240
|
+
onBaseFetchFailed: () => fileLog({ level: 'warn', event: 'base_branch_fetch_skipped', repo: `${owner}/${repoName}`, pr: prNumber, base: params.baseRef }),
|
|
241
|
+
});
|
|
242
|
+
// Diff-aware skip: a new HEAD SHA with the same patch vs base as the last
|
|
243
|
+
// successfully-reviewed SHA (force-push, amend, no-op rebase) doesn't need
|
|
244
|
+
// a fresh review. Post a one-line acknowledgement so the PR author sees we noticed.
|
|
245
|
+
// When the base ref fetch failed earlier, the diff vs base is not measurable;
|
|
246
|
+
// skip the dedup check entirely and don't update the cache after this review.
|
|
247
|
+
let newDiffHash = null;
|
|
248
|
+
try {
|
|
249
|
+
newDiffHash = computeDiffHash(tmpDir, params.baseRef);
|
|
250
|
+
}
|
|
251
|
+
catch { /* base unavailable — proceed with full review, skip cache update */ }
|
|
252
|
+
const prev = newDiffHash ? diffHashes.get(prKey) : undefined;
|
|
253
|
+
if (newDiffHash && prev && prev.hash === newDiffHash && prev.sha !== params.headSha) {
|
|
254
|
+
const prevShort = prev.sha.slice(0, 7);
|
|
255
|
+
const nowShort = params.headSha.slice(0, 7);
|
|
256
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'no_diff_change', sha: params.headSha, prev_sha: prev.sha });
|
|
257
|
+
bLog(`${chalk.dim(fmtTime())} PR #${prNumber} ${params.action} ${chalk.dim('no diff change since last review')}`, `${' '.repeat(FMT_TIME_WIDTH + 2)}prev=${chalk.dim(prevShort)} → ${chalk.dim(nowShort)} ${chalk.dim('(skipped)')}`);
|
|
258
|
+
try {
|
|
259
|
+
await lockOctokit.rest.issues.createComment({
|
|
260
|
+
owner, repo: repoName, issue_number: prNumber,
|
|
261
|
+
body: `✓ No diff change since the last review (was \`${prevShort}\`, now \`${nowShort}\`). Skipping re-review.\n\n<!-- crosscheck: no_diff_change prev_sha=${prev.sha} sha=${params.headSha} -->`,
|
|
262
|
+
});
|
|
263
|
+
fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, kind: 'no_diff_change' });
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'no_diff_comment' }, err);
|
|
267
|
+
}
|
|
268
|
+
await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
board.addPR(key, prNumber, `${owner}/${repoName}`, params.headRef, round);
|
|
272
|
+
boardAdded = true;
|
|
273
|
+
const prLoc = computePRLoc(tmpDir, params.baseRef);
|
|
274
|
+
board.updatePR(key, { prLoc });
|
|
275
|
+
stopHeartbeat = startRemoteLockHeartbeat(lockOctokit, owner, repoName, params.headSha);
|
|
276
|
+
const { verdict } = await runWorkflow({
|
|
277
|
+
owner, repoName, prNumber, pr,
|
|
278
|
+
tmpDir, token, config: effectiveConfig, origin,
|
|
279
|
+
reviewStart,
|
|
280
|
+
log: (msg) => bLog(`${chalk.dim(fmtTime())} ${msg}`),
|
|
281
|
+
onPhaseChange: (label, data) => board.updatePR(key, { label, ...data }),
|
|
282
|
+
crosscheckShas,
|
|
283
|
+
smartSwitchFallback: (ss.active && ss.fallbackVendor) ? ss.fallbackVendor : undefined,
|
|
284
|
+
isRecheckRun,
|
|
285
|
+
round,
|
|
286
|
+
});
|
|
287
|
+
void verdict;
|
|
288
|
+
reviewedPRKeys.add(prKey);
|
|
289
|
+
prRoundCounts.set(prKey, round);
|
|
290
|
+
// Recompute the diff hash AFTER runWorkflow — workflow steps such as
|
|
291
|
+
// `conflict-resolve` or `fix` followed by `recheck` can mutate the checkout,
|
|
292
|
+
// so the pre-workflow hash may not represent the content that was actually
|
|
293
|
+
// reviewed. Caching the stale hash would cause a later force-push back to
|
|
294
|
+
// the pre-mutation diff to be skipped incorrectly as `no_diff_change`.
|
|
295
|
+
if (newDiffHash) {
|
|
296
|
+
let reviewedHash = null;
|
|
297
|
+
try {
|
|
298
|
+
reviewedHash = computeDiffHash(tmpDir, params.baseRef);
|
|
299
|
+
}
|
|
300
|
+
catch { /* base unavailable post-workflow — skip cache update */ }
|
|
301
|
+
if (reviewedHash)
|
|
302
|
+
diffHashes.upsert(prKey, { sha: params.headSha, hash: reviewedHash });
|
|
303
|
+
}
|
|
304
|
+
board.completePR(key, {
|
|
305
|
+
elapsedMs: Date.now() - reviewStart,
|
|
306
|
+
url: `github.com/${owner}/${repoName}/pull/${prNumber}`,
|
|
307
|
+
});
|
|
308
|
+
// Smart-switch recovery confirmation: if a restore attempt is pending and
|
|
309
|
+
// this reviewer matches the previously-degraded vendor, announce full restoration.
|
|
310
|
+
notifyReviewSuccess(reviewer, bLog);
|
|
311
|
+
stopHeartbeat();
|
|
312
|
+
await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
stopHeartbeat();
|
|
316
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
317
|
+
if (boardAdded)
|
|
318
|
+
board.failPR(key, message);
|
|
319
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'review' }, err);
|
|
320
|
+
await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'failure');
|
|
321
|
+
// Smart-switch: when a reviewer hits a subscription limit in cross-vendor mode,
|
|
322
|
+
// degrade to single-vendor with the healthy vendor for the next 30 minutes.
|
|
323
|
+
if (config.mode === 'cross-vendor' && !getSmartSwitch().active && isSubscriptionLimitError(err)) {
|
|
324
|
+
const failedVendor = detectFailedVendor(err);
|
|
325
|
+
if (failedVendor)
|
|
326
|
+
triggerSwitch(failedVendor, message, bLog);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
releasePRLock(owner, repoName, prNumber, params.headSha);
|
|
331
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'setup' }, err);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
inFlight.delete(key);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Start local webhook server
|
|
342
|
+
const server = createWebhookServer(config, webhookSecret, async (event) => {
|
|
343
|
+
const { pull_request: pr, repository: repo } = event;
|
|
344
|
+
const owner = repo.owner.login;
|
|
345
|
+
const repoName = repo.name;
|
|
346
|
+
const prNumber = event.number;
|
|
347
|
+
const key = `${owner}/${repoName}#${prNumber}@${pr.head.sha}`;
|
|
348
|
+
if (inFlight.has(key)) {
|
|
349
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'duplicate' });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (event.action === 'synchronize') {
|
|
353
|
+
const message = await getCommitMessage(owner, repoName, pr.head.sha, token).catch(() => null);
|
|
354
|
+
if (message !== null && isCrosscheckCommitMessage(message)) {
|
|
355
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'crosscheck_commit', sha: pr.head.sha });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Skip synchronize events triggered by our own address commits.
|
|
360
|
+
// crosscheckShas is backed by disk so this also covers SHAs from prior sessions.
|
|
361
|
+
if (crosscheckShas.has(pr.head.sha)) {
|
|
362
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'crosscheck_sha', sha: pr.head.sha });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
await reviewPR({
|
|
366
|
+
owner, repoName, prNumber,
|
|
367
|
+
title: pr.title, body: pr.body, author: pr.user.login,
|
|
368
|
+
headSha: pr.head.sha, headRef: pr.head.ref, headRepo: pr.head.repo?.full_name ?? null,
|
|
369
|
+
baseRef: pr.base.ref, action: event.action,
|
|
370
|
+
});
|
|
371
|
+
}, (msg) => bLog(chalk.dim(fmtTime()) + ' ' + msg), fileLog);
|
|
372
|
+
await new Promise((resolve, reject) => {
|
|
373
|
+
server.on('error', (err) => {
|
|
374
|
+
if (err.code === 'EADDRINUSE') {
|
|
375
|
+
reject(new Error(`Port ${config.server.port} is already in use.\n` +
|
|
376
|
+
` Another crosscheck watch instance is likely running on this port.\n` +
|
|
377
|
+
` Stop it first — running two instances against the same scopes will\n` +
|
|
378
|
+
` register duplicate webhooks and post duplicate reviews.\n` +
|
|
379
|
+
` To run intentionally on a different port, change it in config:\n` +
|
|
380
|
+
` server:\n port: ${config.server.port + 1}`));
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
reject(err);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
server.listen(config.server.port, resolve);
|
|
387
|
+
}).catch((err) => {
|
|
388
|
+
console.error(chalk.red(`\n✗ ${err.message}`));
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
|
391
|
+
// ── Deployment setup ─────────────────────────────────────────────────────
|
|
392
|
+
// Runs before scope building so detected users/orgs feed into webhook registration.
|
|
393
|
+
let effectiveDeployment = config.deployment;
|
|
394
|
+
let sessionOnly = false;
|
|
395
|
+
let selfLogin = null;
|
|
396
|
+
if (opts.personal || opts.team) {
|
|
397
|
+
// One-time flag: auto-detect scopes for this session, no config write.
|
|
398
|
+
effectiveDeployment = opts.personal ? 'personal' : 'team';
|
|
399
|
+
sessionOnly = true;
|
|
400
|
+
const detected = await detectScopesForDeployment(effectiveDeployment, token);
|
|
401
|
+
selfLogin = detected.login;
|
|
402
|
+
config = { ...config, users: detected.users, orgs: detected.orgs, repos: [] };
|
|
403
|
+
}
|
|
404
|
+
else if (opts.reconfigure || !config.deployment) {
|
|
405
|
+
// First run (no deployment in config) or explicit --reconfigure.
|
|
406
|
+
effectiveDeployment = await promptDeploymentMode(opts.reconfigure ? config.deployment : undefined);
|
|
407
|
+
const cfgPath = resolveConfigPath(configPath) ?? join(process.cwd(), 'crosscheck.config.yml');
|
|
408
|
+
const detected = await detectScopesForDeployment(effectiveDeployment, token);
|
|
409
|
+
selfLogin = detected.login;
|
|
410
|
+
// force=true only for --reconfigure; first-run preserves any manually-configured orgs/authors
|
|
411
|
+
patchDeploymentConfig(cfgPath, effectiveDeployment, detected.login, detected.orgs, !!opts.reconfigure);
|
|
412
|
+
config = loadConfig(configPath);
|
|
413
|
+
console.log(`\n ${chalk.green('✓')} deployment set to ${chalk.cyan(effectiveDeployment)} ${chalk.dim(`(saved to ${cfgPath})`)}`);
|
|
414
|
+
}
|
|
415
|
+
// ── Scope building ────────────────────────────────────────────────────────
|
|
416
|
+
// Determine scopes once — these don't change between tunnel reconnects.
|
|
417
|
+
// orgs, users, and repos are additive: all configured sources contribute scopes.
|
|
418
|
+
const rawScopes = [];
|
|
419
|
+
for (const org of config.orgs)
|
|
420
|
+
rawScopes.push({ org });
|
|
421
|
+
const userRepoResults = [];
|
|
422
|
+
if (config.users.length > 0) {
|
|
423
|
+
// selfLogin is known when we just ran detection; fall back to detectGitHubLogin() for
|
|
424
|
+
// existing configs so personal-mode users still get private repos enumerated.
|
|
425
|
+
if (!selfLogin)
|
|
426
|
+
selfLogin = detectGitHubLogin();
|
|
427
|
+
for (const user of config.users) {
|
|
428
|
+
try {
|
|
429
|
+
const repos = await listUserRepos(user, token, user === selfLogin);
|
|
430
|
+
for (const { owner, name } of repos)
|
|
431
|
+
rawScopes.push({ owner, repo: name });
|
|
432
|
+
userRepoResults.push({ user, count: repos.length });
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
436
|
+
userRepoResults.push({ user, error: msg });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Validate explicitly-configured repos and skip any that are inaccessible.
|
|
441
|
+
const repoChecks = await Promise.all(config.repos.map(async ({ owner, name }) => ({
|
|
442
|
+
owner, name,
|
|
443
|
+
ok: await checkRepoAccessible(owner, name, token).catch(() => false),
|
|
444
|
+
})));
|
|
445
|
+
for (const { owner, name, ok } of repoChecks) {
|
|
446
|
+
if (ok) {
|
|
447
|
+
rawScopes.push({ owner, repo: name });
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
console.log(chalk.yellow(` ✗ repo not accessible: ${owner}/${name} — skipped`));
|
|
451
|
+
fileLog({ level: 'warn', event: 'repo_inaccessible', repo: `${owner}/${name}` });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Collapse repo scopes already covered by an org scope. Registering both produces
|
|
455
|
+
// duplicate webhook deliveries from GitHub (one per registered hook), which our
|
|
456
|
+
// in-flight dedup absorbs but still clutters logs and burns signature-verification cycles.
|
|
457
|
+
const { scopes, dropped: droppedRepos, fallbackRepos } = dedupScopes(rawScopes);
|
|
458
|
+
for (const [org, repos] of droppedRepos) {
|
|
459
|
+
for (const repo of repos) {
|
|
460
|
+
const fallback = fallbackRepos.get(org)?.find(s => s.repo === repo);
|
|
461
|
+
fileLog({ level: 'info', event: 'scope_deduped', org, owner: fallback?.owner ?? org, repo, reason: 'covered_by_org_scope' });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (scopes.length === 0 && config.tunnel.backend !== 'smee') {
|
|
465
|
+
// localhost.run needs a target repo to auto-register webhooks.
|
|
466
|
+
// smee users register the webhook manually — no target required here.
|
|
467
|
+
const detected = detectCurrentRepo();
|
|
468
|
+
if (!detected) {
|
|
469
|
+
console.error(chalk.red('No repos, users, or orgs configured. Run inside a git repo or set repos/users/orgs in config.'));
|
|
470
|
+
server.close(() => process.exit(1));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
scopes.push({ owner: detected.owner, repo: detected.repo });
|
|
474
|
+
}
|
|
475
|
+
function webhookFailureReason(msg, isOrg) {
|
|
476
|
+
const isCreds = /bad credentials|\[401\]/i.test(msg);
|
|
477
|
+
const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(msg)
|
|
478
|
+
|| (isOrg && /\[404\]/i.test(msg));
|
|
479
|
+
return isCreds ? 'creds' : isScope ? 'scope' : `other:${msg}`;
|
|
480
|
+
}
|
|
481
|
+
function addWebhookFailure(failures, reason, label, msg) {
|
|
482
|
+
const bucket = failures.get(reason);
|
|
483
|
+
if (bucket) {
|
|
484
|
+
bucket.labels.push(label);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
failures.set(reason, { labels: [label], msg });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Mutable tunnel session state — replaced on each reconnect
|
|
491
|
+
let currentTunnelProc = null;
|
|
492
|
+
let currentRegistered = [];
|
|
493
|
+
let running = true;
|
|
494
|
+
async function deleteCurrentWebhooks() {
|
|
495
|
+
for (const hook of currentRegistered) {
|
|
496
|
+
try {
|
|
497
|
+
if (hook.type === 'org') {
|
|
498
|
+
await deleteOrgWebhook(hook.org, hook.hookId, token);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
await deleteRepoWebhook(hook.owner, hook.repo, hook.hookId, token);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch { /* best-effort */ }
|
|
505
|
+
}
|
|
506
|
+
currentRegistered = [];
|
|
507
|
+
}
|
|
508
|
+
const cleanup = async () => {
|
|
509
|
+
running = false;
|
|
510
|
+
board.stop();
|
|
511
|
+
stopSmartSwitch();
|
|
512
|
+
console.log('\nCleaning up...');
|
|
513
|
+
currentTunnelProc?.kill();
|
|
514
|
+
await deleteCurrentWebhooks();
|
|
515
|
+
fileLog({ level: 'info', event: 'session_end', command: 'watch' });
|
|
516
|
+
server.close(() => process.exit(0));
|
|
517
|
+
};
|
|
518
|
+
process.on('SIGINT', () => { void cleanup(); });
|
|
519
|
+
process.on('SIGTERM', () => { void cleanup(); });
|
|
520
|
+
// ── Static startup banner ─────────────────────────────────────────────────
|
|
521
|
+
console.log(chalk.dim(`\n "${randomFortune()}"\n`));
|
|
522
|
+
console.log(chalk.bold('crosscheck watch\n'));
|
|
523
|
+
if (effectiveDeployment) {
|
|
524
|
+
const deployLabel = sessionOnly
|
|
525
|
+
? chalk.dim(`${effectiveDeployment} (session only — not saved)`)
|
|
526
|
+
: chalk.cyan(effectiveDeployment);
|
|
527
|
+
console.log(` profile ${deployLabel} · ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
console.log(` profile ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
|
|
531
|
+
}
|
|
532
|
+
if (config.orgs.length > 0) {
|
|
533
|
+
console.log(` orgs ${chalk.cyan(config.orgs.join(', '))}`);
|
|
534
|
+
}
|
|
535
|
+
if (config.users.length > 0) {
|
|
536
|
+
const userParts = userRepoResults.map(r => {
|
|
537
|
+
if ('error' in r)
|
|
538
|
+
return chalk.yellow(`${r.user} (⚠ list failed)`);
|
|
539
|
+
return `${chalk.cyan(r.user)} ${chalk.dim(`(${r.count} repos)`)}`;
|
|
540
|
+
});
|
|
541
|
+
console.log(` users ${userParts.join(', ')}`);
|
|
542
|
+
}
|
|
543
|
+
if (config.orgs.length === 0 && config.users.length === 0) {
|
|
544
|
+
const labels = scopes.map(s => 'org' in s ? s.org : `${s.owner}/${s.repo}`);
|
|
545
|
+
console.log(` repos ${chalk.cyan(labels.join(', '))}`);
|
|
546
|
+
}
|
|
547
|
+
const cfgPath = resolveConfigPath(configPath);
|
|
548
|
+
console.log(` config ${chalk.dim(cfgPath ?? 'none (using defaults)')} ${chalk.dim('← edit to change above')}`);
|
|
549
|
+
if (effectiveDeployment === 'team' && config.routing.allowed_authors.length === 0) {
|
|
550
|
+
console.log(` authors ${chalk.dim('all PRs (team mode)')}`);
|
|
551
|
+
}
|
|
552
|
+
else if (config.routing.allowed_authors.length > 0) {
|
|
553
|
+
console.log(` authors ${chalk.cyan(config.routing.allowed_authors.join(', '))}`);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
console.log();
|
|
557
|
+
console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('No author filter set — all PRs in monitored orgs/repos will be reviewed.')}`);
|
|
558
|
+
console.log(` ${chalk.dim('Run')} ${chalk.cyan('crosscheck watch --reconfigure')} ${chalk.dim('to set up a deployment mode.')}`);
|
|
559
|
+
}
|
|
560
|
+
// Warn when author_routes will be silently bypassed (cross-vendor + both vendors enabled)
|
|
561
|
+
// so users understand why their configured mapping isn't applying.
|
|
562
|
+
const bothVendorsEnabled = config.mode === 'cross-vendor'
|
|
563
|
+
&& config.vendors.claude.enabled
|
|
564
|
+
&& config.vendors.codex.enabled;
|
|
565
|
+
const routedAllowedAuthors = bothVendorsEnabled
|
|
566
|
+
? Object.entries(config.routing.author_routes).filter(([login]) => config.routing.allowed_authors.length === 0 || config.routing.allowed_authors.includes(login))
|
|
567
|
+
: [];
|
|
568
|
+
if (routedAllowedAuthors.length > 0) {
|
|
569
|
+
console.log();
|
|
570
|
+
console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('author_routes bypassed in cross-vendor mode (both vendors enabled).')}`);
|
|
571
|
+
for (const [login, vendor] of routedAllowedAuthors) {
|
|
572
|
+
console.log(` ${chalk.dim(`${login} → ${vendor}`)}`);
|
|
573
|
+
}
|
|
574
|
+
console.log(` ${chalk.dim('PRs without attribution markers (body / Co-Authored-By / branch prefix)')}`);
|
|
575
|
+
console.log(` ${chalk.dim('fall through to')} ${chalk.cyan(`fallback_reviewer: ${config.routing.fallback_reviewer ?? 'skip'}`)} ${chalk.dim('instead.')}`);
|
|
576
|
+
}
|
|
577
|
+
// Warn when repo scopes were dropped because their owner is also an org scope —
|
|
578
|
+
// both being registered causes duplicate webhook deliveries from GitHub.
|
|
579
|
+
if (droppedRepos.size > 0) {
|
|
580
|
+
console.log();
|
|
581
|
+
console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('redundant repo scopes — org webhook already covers these:')}`);
|
|
582
|
+
for (const [org, repos] of droppedRepos) {
|
|
583
|
+
console.log(` ${chalk.dim(`${org}/{${repos.join(', ')}}`)}`);
|
|
584
|
+
}
|
|
585
|
+
console.log(` ${chalk.dim('Remove these entries from')} ${chalk.cyan('config.repos')} ${chalk.dim('to silence this warning.')}`);
|
|
586
|
+
}
|
|
587
|
+
console.log();
|
|
588
|
+
// Board starts after the banner — all output below is live-updated
|
|
589
|
+
board.start();
|
|
590
|
+
// ── Backtrace scan ────────────────────────────────────────────────────────
|
|
591
|
+
if (opts.backtrace === true || (opts.backtrace !== false && config.backtrace.enabled)) {
|
|
592
|
+
void (async () => {
|
|
593
|
+
try {
|
|
594
|
+
cLog(`${chalk.dim('✦')} backtrace: scanning open PRs in monitored scope...`);
|
|
595
|
+
const { queued, alreadyReviewed, skippedAuthor } = await scanUnreviewedPRs(scopes, config, token);
|
|
596
|
+
cLog(`${chalk.dim('✦')} backtrace: ${queued.length} unreviewed, ${alreadyReviewed} already reviewed, ${skippedAuthor} skipped (author filter)`);
|
|
597
|
+
void Promise.all(queued.map(pr => reviewPR({
|
|
598
|
+
owner: pr.owner, repoName: pr.repo, prNumber: pr.number,
|
|
599
|
+
title: pr.title, body: pr.body, author: pr.author,
|
|
600
|
+
headSha: pr.headSha, headRef: pr.headRef, headRepo: pr.headRepo,
|
|
601
|
+
baseRef: pr.baseRef, action: 'backtrace',
|
|
602
|
+
})));
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
606
|
+
cLog(`${chalk.yellow('⚠')} backtrace: scan failed — ${msg}`);
|
|
607
|
+
}
|
|
608
|
+
})();
|
|
609
|
+
}
|
|
610
|
+
// ── Smee mode ─────────────────────────────────────────────────────────────
|
|
611
|
+
// Smee channel URL is stable — webhooks are registered once and survive restarts.
|
|
612
|
+
if (config.tunnel.backend === 'smee') {
|
|
613
|
+
const channelUrl = config.tunnel.smee_channel;
|
|
614
|
+
if (!channelUrl) {
|
|
615
|
+
board.stop();
|
|
616
|
+
console.error(chalk.red('✗ tunnel.smee_channel is required when tunnel.backend: smee'));
|
|
617
|
+
console.error(chalk.dim(' Visit https://smee.io/new to get a free channel URL.'));
|
|
618
|
+
server.close(() => process.exit(1));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
board.setTunnel('smee', channelUrl, true);
|
|
622
|
+
fileLog({ level: 'info', event: 'tunnel_opened', url: channelUrl, backend: 'smee' });
|
|
623
|
+
// Register webhooks pointing at the smee channel URL (idempotent — skip if already set).
|
|
624
|
+
// The smee channel URL never changes, so this survives restarts without creating duplicates.
|
|
625
|
+
let smeeOk = 0, smeeFail = 0;
|
|
626
|
+
let smeeTotal = scopes.length;
|
|
627
|
+
const smeeFailuresByReason = new Map();
|
|
628
|
+
const succeededOrgs = new Set();
|
|
629
|
+
for (const scope of scopes) {
|
|
630
|
+
const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
|
|
631
|
+
try {
|
|
632
|
+
let existing;
|
|
633
|
+
if ('org' in scope) {
|
|
634
|
+
existing = await findOrgWebhook(scope.org, channelUrl, token);
|
|
635
|
+
if (!existing)
|
|
636
|
+
await registerOrgWebhook(scope.org, channelUrl, webhookSecret, token);
|
|
637
|
+
succeededOrgs.add(scope.org);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
existing = await findRepoWebhook(scope.owner, scope.repo, channelUrl, token);
|
|
641
|
+
if (!existing)
|
|
642
|
+
await registerRepoWebhook(scope.owner, scope.repo, channelUrl, webhookSecret, token);
|
|
643
|
+
}
|
|
644
|
+
smeeOk++;
|
|
645
|
+
fileLog({ level: 'info', event: existing ? 'webhook_active' : 'webhook_registered', scope: label, url: channelUrl });
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
649
|
+
const fallbackOrg = 'org' in scope ? scope.org : null;
|
|
650
|
+
smeeFail++;
|
|
651
|
+
addWebhookFailure(smeeFailuresByReason, webhookFailureReason(msg, fallbackOrg !== null), label, msg);
|
|
652
|
+
fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: msg });
|
|
653
|
+
const fallback = fallbackOrg ? fallbackRepos.get(fallbackOrg) ?? [] : [];
|
|
654
|
+
smeeTotal += fallback.length;
|
|
655
|
+
await Promise.all(fallback.map(async ({ owner, repo }) => {
|
|
656
|
+
const repoLabel = `${owner}/${repo}`;
|
|
657
|
+
try {
|
|
658
|
+
const existing = await findRepoWebhook(owner, repo, channelUrl, token);
|
|
659
|
+
if (!existing)
|
|
660
|
+
await registerRepoWebhook(owner, repo, channelUrl, webhookSecret, token);
|
|
661
|
+
smeeOk++;
|
|
662
|
+
fileLog({ level: 'info', event: existing ? 'webhook_active' : 'webhook_registered', scope: repoLabel, url: channelUrl, fallback_for_org: fallbackOrg });
|
|
663
|
+
}
|
|
664
|
+
catch (fallbackErr) {
|
|
665
|
+
const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
666
|
+
smeeFail++;
|
|
667
|
+
addWebhookFailure(smeeFailuresByReason, webhookFailureReason(fallbackMsg, false), repoLabel, fallbackMsg);
|
|
668
|
+
fileLog({ level: 'warn', event: 'webhook_error', scope: repoLabel, message: fallbackMsg, fallback_for_org: fallbackOrg });
|
|
669
|
+
}
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Cleanup: org hook succeeded → delete any stale repo-level hooks for repos now covered by the org hook.
|
|
674
|
+
// Without this, a repo hook registered before the org scope was added would keep firing,
|
|
675
|
+
// re-introducing the duplicate-delivery problem the scope dedup is meant to fix.
|
|
676
|
+
for (const [org, repos] of droppedRepos) {
|
|
677
|
+
if (!succeededOrgs.has(org))
|
|
678
|
+
continue;
|
|
679
|
+
for (const repo of repos) {
|
|
680
|
+
try {
|
|
681
|
+
const staleId = await findRepoWebhook(org, repo, channelUrl, token);
|
|
682
|
+
if (staleId) {
|
|
683
|
+
await deleteRepoWebhook(org, repo, staleId, token);
|
|
684
|
+
fileLog({ level: 'info', event: 'webhook_deleted', scope: `${org}/${repo}`, reason: 'covered_by_org_hook' });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch { /* best-effort */ }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Grouped failure summary — one block per error type
|
|
691
|
+
for (const [reason, { labels, msg }] of smeeFailuresByReason) {
|
|
692
|
+
const count = labels.length;
|
|
693
|
+
const shown = labels.slice(0, 5);
|
|
694
|
+
const overflow = count - shown.length;
|
|
695
|
+
const sample = shown.join(', ') + (overflow > 0 ? ` +${overflow} more` : '');
|
|
696
|
+
const noun = count === 1 ? 'webhook' : 'webhooks';
|
|
697
|
+
if (reason === 'creds') {
|
|
698
|
+
cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: token invalid — run: ${chalk.cyan('gh auth refresh')}`);
|
|
699
|
+
}
|
|
700
|
+
else if (reason === 'scope') {
|
|
701
|
+
cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: missing scope — run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: ${msg}`);
|
|
705
|
+
}
|
|
706
|
+
cLog(` ${chalk.dim(sample)}`);
|
|
707
|
+
}
|
|
708
|
+
cLog(`${smeeFail === 0 ? chalk.green('✓') : chalk.yellow('⚠')} webhooks registered: ${smeeOk}/${smeeTotal}${smeeFail > 0 ? ` (${smeeFail} failed)` : ''}`);
|
|
709
|
+
let smeeRetryDelay = 5_000;
|
|
710
|
+
while (running) {
|
|
711
|
+
const smeeProc = spawn('smee', [
|
|
712
|
+
'--url', channelUrl,
|
|
713
|
+
'--path', config.server.webhook_path,
|
|
714
|
+
'--port', String(config.server.port),
|
|
715
|
+
], { stdio: 'pipe' });
|
|
716
|
+
currentTunnelProc = smeeProc;
|
|
717
|
+
try {
|
|
718
|
+
await new Promise((resolve, reject) => {
|
|
719
|
+
smeeProc.on('error', (err) => {
|
|
720
|
+
if (err.code === 'ENOENT') {
|
|
721
|
+
reject(new Error('smee-client not installed — run: npm install -g smee-client'));
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
reject(err);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
smeeProc.on('exit', () => resolve());
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
board.stop();
|
|
732
|
+
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
733
|
+
server.close(() => process.exit(1));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (!running)
|
|
737
|
+
break;
|
|
738
|
+
currentTunnelProc = null;
|
|
739
|
+
board.setTunnel('smee', channelUrl, false);
|
|
740
|
+
cLog(chalk.yellow(`smee relay exited — reconnecting in ${smeeRetryDelay / 1000}s`));
|
|
741
|
+
fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true, backend: 'smee' });
|
|
742
|
+
await new Promise(r => setTimeout(r, smeeRetryDelay));
|
|
743
|
+
smeeRetryDelay = Math.min(smeeRetryDelay * 2, 60_000);
|
|
744
|
+
board.setTunnel('smee', channelUrl, true);
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// ── localhost.run mode ────────────────────────────────────────────────────
|
|
749
|
+
let reconnectDelay = 5_000;
|
|
750
|
+
while (running) {
|
|
751
|
+
board.setTunnel('localhost.run', null, false);
|
|
752
|
+
let tunnelUrl;
|
|
753
|
+
let tunnelProc;
|
|
754
|
+
try {
|
|
755
|
+
;
|
|
756
|
+
({ url: tunnelUrl, proc: tunnelProc } = await openTunnel(config.server.port));
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
if (!running)
|
|
760
|
+
break;
|
|
761
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
762
|
+
cLog(chalk.yellow(`tunnel failed: ${msg} — retrying in ${reconnectDelay / 1000}s`));
|
|
763
|
+
fileLog({ level: 'warn', event: 'tunnel_error', message: msg });
|
|
764
|
+
await new Promise(r => setTimeout(r, reconnectDelay));
|
|
765
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
reconnectDelay = 5_000; // reset backoff on success
|
|
769
|
+
currentTunnelProc = tunnelProc;
|
|
770
|
+
board.setTunnel('localhost.run', tunnelUrl, true);
|
|
771
|
+
cLog(`${chalk.green('✓')} tunnel ready: ${chalk.cyan(tunnelUrl)}`);
|
|
772
|
+
fileLog({ level: 'info', event: 'tunnel_opened', url: tunnelUrl });
|
|
773
|
+
// Register webhooks in parallel: dedup check → register with backoff → aggregate summary
|
|
774
|
+
const webhookUrl = `${tunnelUrl}${webhookPath}`;
|
|
775
|
+
currentRegistered = [];
|
|
776
|
+
let hookOk = 0, hookFail = 0;
|
|
777
|
+
let hookTotal = scopes.length;
|
|
778
|
+
const failuresByReason = new Map();
|
|
779
|
+
await Promise.all(scopes.map(async (scope) => {
|
|
780
|
+
const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
|
|
781
|
+
// Dedup: skip if a hook for this exact URL already exists (e.g. previous session not cleaned up)
|
|
782
|
+
let existingId = null;
|
|
783
|
+
try {
|
|
784
|
+
existingId = 'org' in scope
|
|
785
|
+
? await findOrgWebhook(scope.org, webhookUrl, token)
|
|
786
|
+
: await findRepoWebhook(scope.owner, scope.repo, webhookUrl, token);
|
|
787
|
+
}
|
|
788
|
+
catch { /* ignore — proceed to register */ }
|
|
789
|
+
if (existingId !== null) {
|
|
790
|
+
currentRegistered.push('org' in scope
|
|
791
|
+
? { type: 'org', org: scope.org, hookId: existingId }
|
|
792
|
+
: { type: 'repo', owner: scope.owner, repo: scope.repo, hookId: existingId });
|
|
793
|
+
hookOk++;
|
|
794
|
+
fileLog({ level: 'info', event: 'webhook_active', scope: label, url: webhookUrl });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// Register with exponential back-off: delay 2s then 4s before giving up
|
|
798
|
+
let hookId = null;
|
|
799
|
+
let lastErr = '';
|
|
800
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
801
|
+
if (attempt > 0) {
|
|
802
|
+
const delay = 2 ** attempt * 1000;
|
|
803
|
+
fileLog({ level: 'warn', event: 'webhook_register_retry', scope: label, attempt, message: lastErr });
|
|
804
|
+
await new Promise(r => setTimeout(r, delay));
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
hookId = 'org' in scope
|
|
808
|
+
? await registerOrgWebhook(scope.org, webhookUrl, webhookSecret, token)
|
|
809
|
+
: await registerRepoWebhook(scope.owner, scope.repo, webhookUrl, webhookSecret, token);
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (hookId !== null) {
|
|
817
|
+
currentRegistered.push('org' in scope
|
|
818
|
+
? { type: 'org', org: scope.org, hookId }
|
|
819
|
+
: { type: 'repo', owner: scope.owner, repo: scope.repo, hookId });
|
|
820
|
+
hookOk++;
|
|
821
|
+
fileLog({ level: 'info', event: 'webhook_registered', scope: label, url: webhookUrl });
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
const fallbackOrg = 'org' in scope ? scope.org : null;
|
|
825
|
+
hookFail++;
|
|
826
|
+
addWebhookFailure(failuresByReason, webhookFailureReason(lastErr, fallbackOrg !== null), label, lastErr);
|
|
827
|
+
fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: lastErr });
|
|
828
|
+
const fallback = fallbackOrg ? fallbackRepos.get(fallbackOrg) ?? [] : [];
|
|
829
|
+
hookTotal += fallback.length;
|
|
830
|
+
await Promise.all(fallback.map(async ({ owner, repo }) => {
|
|
831
|
+
const repoLabel = `${owner}/${repo}`;
|
|
832
|
+
let fallbackHookId = null;
|
|
833
|
+
let fallbackLastErr = '';
|
|
834
|
+
try {
|
|
835
|
+
fallbackHookId = await findRepoWebhook(owner, repo, webhookUrl, token);
|
|
836
|
+
}
|
|
837
|
+
catch { /* ignore — proceed to register */ }
|
|
838
|
+
if (fallbackHookId === null) {
|
|
839
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
840
|
+
if (attempt > 0) {
|
|
841
|
+
const delay = 2 ** attempt * 1000;
|
|
842
|
+
fileLog({ level: 'warn', event: 'webhook_register_retry', scope: repoLabel, attempt, message: fallbackLastErr, fallback_for_org: fallbackOrg });
|
|
843
|
+
await new Promise(r => setTimeout(r, delay));
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
fallbackHookId = await registerRepoWebhook(owner, repo, webhookUrl, webhookSecret, token);
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
catch (fallbackErr) {
|
|
850
|
+
fallbackLastErr = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (fallbackHookId !== null) {
|
|
855
|
+
currentRegistered.push({ type: 'repo', owner, repo, hookId: fallbackHookId });
|
|
856
|
+
hookOk++;
|
|
857
|
+
fileLog({ level: 'info', event: 'webhook_registered', scope: repoLabel, url: webhookUrl, fallback_for_org: fallbackOrg });
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
hookFail++;
|
|
861
|
+
addWebhookFailure(failuresByReason, webhookFailureReason(fallbackLastErr, false), repoLabel, fallbackLastErr);
|
|
862
|
+
fileLog({ level: 'warn', event: 'webhook_error', scope: repoLabel, message: fallbackLastErr, fallback_for_org: fallbackOrg });
|
|
863
|
+
}
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
}));
|
|
867
|
+
// Print grouped failure summary — one block per error type, not one line per repo
|
|
868
|
+
for (const [reason, { labels, msg }] of failuresByReason) {
|
|
869
|
+
const count = labels.length;
|
|
870
|
+
const shown = labels.slice(0, 5);
|
|
871
|
+
const overflow = count - shown.length;
|
|
872
|
+
const sample = shown.join(', ') + (overflow > 0 ? ` +${overflow} more` : '');
|
|
873
|
+
const noun = count === 1 ? 'webhook' : 'webhooks';
|
|
874
|
+
if (reason === 'creds') {
|
|
875
|
+
bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: token invalid — run: ${chalk.cyan('gh auth refresh')}`);
|
|
876
|
+
}
|
|
877
|
+
else if (reason === 'scope') {
|
|
878
|
+
bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: missing scope — run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: ${msg}`);
|
|
882
|
+
}
|
|
883
|
+
bLog(` ${chalk.dim(sample)}`);
|
|
884
|
+
bLog(` manual Payload URL: ${chalk.cyan(webhookUrl)}`);
|
|
885
|
+
}
|
|
886
|
+
// Single aggregated connectivity line instead of one per repo
|
|
887
|
+
cLog(`${hookFail === 0 ? chalk.green('✓') : chalk.yellow('⚠')} webhooks registered: ${hookOk}/${hookTotal}${hookFail > 0 ? ` (${hookFail} failed)` : ''}`);
|
|
888
|
+
fileLog({ level: 'info', event: 'webhooks_registered', count: hookOk, total: hookTotal, failed: hookFail, url: webhookUrl });
|
|
889
|
+
// Wait for this tunnel session to end.
|
|
890
|
+
// Health check kills the SSH proc if lhr.life goes dead without exiting.
|
|
891
|
+
await waitForTunnelEnd(tunnelProc, tunnelUrl);
|
|
892
|
+
if (!running)
|
|
893
|
+
break;
|
|
894
|
+
// Clean up webhooks tied to the old URL before reconnecting
|
|
895
|
+
await deleteCurrentWebhooks();
|
|
896
|
+
board.setTunnel('localhost.run', tunnelUrl, false);
|
|
897
|
+
cLog(chalk.yellow('tunnel disconnected — reconnecting in 5s...'));
|
|
898
|
+
fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true });
|
|
899
|
+
await new Promise(r => setTimeout(r, reconnectDelay));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
//# sourceMappingURL=watch.js.map
|