@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/src/routes/local.js
CHANGED
|
@@ -24,8 +24,10 @@ const { broadcastReviewEvent } = require('../events/review-events');
|
|
|
24
24
|
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
25
25
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
|
|
26
26
|
const { mergeInstructions } = require('../utils/instructions');
|
|
27
|
-
const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
28
|
-
const {
|
|
27
|
+
const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides, getSummaryEnabled, getTourEnabled } = require('../config');
|
|
28
|
+
const { backgroundQueue } = require('../ai/background-queue');
|
|
29
|
+
const localReview = require('../local-review');
|
|
30
|
+
const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = localReview;
|
|
29
31
|
const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
|
|
30
32
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
31
33
|
const { getShaAbbrevLength } = require('../git/sha-abbrev');
|
|
@@ -36,6 +38,12 @@ const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
|
|
|
36
38
|
const { CommentRepository } = require('../database');
|
|
37
39
|
const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
|
|
38
40
|
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
41
|
+
const reviewsRouter = require('./reviews');
|
|
42
|
+
const summaryGenerator = require('../ai/summary-generator');
|
|
43
|
+
const tourGenerator = require('../ai/tour-generator');
|
|
44
|
+
const { parseUnifiedDiffPatches } = require('../utils/diff-file-list');
|
|
45
|
+
const { parseHunks } = require('../utils/diff-hunks');
|
|
46
|
+
const { hashHunk } = require('../ai/hunk-hashing');
|
|
39
47
|
const {
|
|
40
48
|
activeAnalyses,
|
|
41
49
|
localReviewDiffs,
|
|
@@ -489,10 +497,13 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
489
497
|
const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
|
|
490
498
|
|
|
491
499
|
// Branch detection: when no uncommitted changes, check if branch has commits ahead
|
|
500
|
+
const { resolveHostBinding: _resolveHostBindingForBranch } = require('../config');
|
|
501
|
+
const branchBinding = repository ? _resolveHostBindingForBranch(repository, config) : null;
|
|
492
502
|
const branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
|
|
493
503
|
repository,
|
|
494
504
|
diff,
|
|
495
|
-
githubToken: getGitHubToken(config),
|
|
505
|
+
githubToken: branchBinding?.token || getGitHubToken(config),
|
|
506
|
+
hostBinding: branchBinding,
|
|
496
507
|
enableGraphite: config.enable_graphite === true
|
|
497
508
|
});
|
|
498
509
|
|
|
@@ -519,6 +530,30 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
519
530
|
}
|
|
520
531
|
});
|
|
521
532
|
|
|
533
|
+
(async () => {
|
|
534
|
+
await summaryGenerator.kickOffSummaryJob({
|
|
535
|
+
db,
|
|
536
|
+
config,
|
|
537
|
+
reviewId: sessionId,
|
|
538
|
+
diffText: diff,
|
|
539
|
+
worktreePath: repoPath,
|
|
540
|
+
reviewContext: { prTitle: branch },
|
|
541
|
+
trigger: 'auto'
|
|
542
|
+
});
|
|
543
|
+
})().catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
|
|
544
|
+
|
|
545
|
+
(async () => {
|
|
546
|
+
await tourGenerator.kickOffTourJob({
|
|
547
|
+
db,
|
|
548
|
+
config,
|
|
549
|
+
reviewId: sessionId,
|
|
550
|
+
diffText: diff,
|
|
551
|
+
worktreePath: repoPath,
|
|
552
|
+
reviewContext: { prTitle: branch },
|
|
553
|
+
trigger: 'auto'
|
|
554
|
+
});
|
|
555
|
+
})().catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
|
|
556
|
+
|
|
522
557
|
} catch (error) {
|
|
523
558
|
logger.error(`Error starting local review: ${error.message}`);
|
|
524
559
|
res.status(500).json({
|
|
@@ -684,13 +719,18 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
684
719
|
&& branchName && branchName !== 'HEAD' && branchName !== 'unknown'
|
|
685
720
|
&& repositoryName && repositoryName.includes('/')) {
|
|
686
721
|
const bgConfig = req.app.get('config') || {};
|
|
687
|
-
const
|
|
722
|
+
const { resolveHostBinding: _resolveHostBinding } = require('../config');
|
|
723
|
+
const bgBinding = _resolveHostBinding(repositoryName, bgConfig);
|
|
724
|
+
const bgToken = bgBinding.token;
|
|
688
725
|
const bgT0 = Date.now();
|
|
689
726
|
const { detectBaseBranch } = require('../git/base-branch');
|
|
690
727
|
detectBaseBranch(review.local_path, branchName, {
|
|
691
728
|
repository: repositoryName,
|
|
692
729
|
enableGraphite: bgConfig.enable_graphite === true,
|
|
693
|
-
_deps: bgToken ? {
|
|
730
|
+
_deps: bgToken ? {
|
|
731
|
+
getGitHubToken: () => bgToken,
|
|
732
|
+
getHostBinding: () => bgBinding
|
|
733
|
+
} : undefined
|
|
694
734
|
}).then(detection => {
|
|
695
735
|
if (detection && detection.baseBranch) {
|
|
696
736
|
return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
|
|
@@ -719,6 +759,48 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
719
759
|
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
720
760
|
}
|
|
721
761
|
|
|
762
|
+
// Background: re-trigger hunk summary + tour generation on review load.
|
|
763
|
+
// Self-invoked so any rejection here cannot reach the outer try/catch
|
|
764
|
+
// and call res.status(500) on an already-flushed response.
|
|
765
|
+
(async () => {
|
|
766
|
+
let bgDiffText = getLocalReviewDiff(reviewId)?.diff;
|
|
767
|
+
if (!bgDiffText) {
|
|
768
|
+
const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
|
|
769
|
+
bgDiffText = persistedDiff?.diff;
|
|
770
|
+
}
|
|
771
|
+
if (!bgDiffText) {
|
|
772
|
+
logger.debug(`Skipping background AI kickoff for review ${reviewId}: no diff available`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const reviewContext = { prTitle: review.name || branchName };
|
|
776
|
+
const results = await Promise.allSettled([
|
|
777
|
+
summaryGenerator.kickOffSummaryJob({
|
|
778
|
+
db,
|
|
779
|
+
config: localConfig,
|
|
780
|
+
reviewId,
|
|
781
|
+
diffText: bgDiffText,
|
|
782
|
+
worktreePath: review.local_path,
|
|
783
|
+
reviewContext,
|
|
784
|
+
trigger: 'auto'
|
|
785
|
+
}),
|
|
786
|
+
tourGenerator.kickOffTourJob({
|
|
787
|
+
db,
|
|
788
|
+
config: localConfig,
|
|
789
|
+
reviewId,
|
|
790
|
+
diffText: bgDiffText,
|
|
791
|
+
worktreePath: review.local_path,
|
|
792
|
+
reviewContext,
|
|
793
|
+
trigger: 'auto'
|
|
794
|
+
})
|
|
795
|
+
]);
|
|
796
|
+
const labels = ['Hunk summary', 'Tour'];
|
|
797
|
+
results.forEach((r, i) => {
|
|
798
|
+
if (r.status === 'rejected') {
|
|
799
|
+
logger.warn(`${labels[i]} kickoff failed for review ${reviewId}: ${r.reason?.message || r.reason}`);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
})().catch((err) => logger.warn(`Background AI kickoff failed for review ${reviewId}: ${err.message}`));
|
|
803
|
+
|
|
722
804
|
} catch (error) {
|
|
723
805
|
logger.error('Error fetching local review:', error.stack || error.message);
|
|
724
806
|
res.status(500).json({
|
|
@@ -805,7 +887,11 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
805
887
|
|
|
806
888
|
if ((hideWhitespace || baseBranchOverride) && review.local_path) {
|
|
807
889
|
try {
|
|
808
|
-
|
|
890
|
+
// Call via the module namespace so tests can stub `generateScopedDiff`
|
|
891
|
+
// with `vi.spyOn(localReview, 'generateScopedDiff')`. The destructured
|
|
892
|
+
// top-level binding is captured at require time and would not honor a
|
|
893
|
+
// spy.
|
|
894
|
+
const wsResult = await localReview.generateScopedDiff(review.local_path, scopeStart, scopeEnd, baseBranch, { hideWhitespace });
|
|
809
895
|
diffData = { diff: wsResult.diff, stats: wsResult.stats };
|
|
810
896
|
} catch (wsError) {
|
|
811
897
|
logger.warn(`Could not generate diff for review #${reviewId}: ${wsError.message}`);
|
|
@@ -854,6 +940,52 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
854
940
|
}
|
|
855
941
|
}
|
|
856
942
|
|
|
943
|
+
// Compute per-file hunk hashes for the hunk-summary feature.
|
|
944
|
+
//
|
|
945
|
+
// The frontend stamps these hashes onto rendered hunks BY INDEX
|
|
946
|
+
// (`hunkHashes[blockIndex]`), so the array MUST be aligned to the
|
|
947
|
+
// diff that was actually returned to the client. Two cases:
|
|
948
|
+
//
|
|
949
|
+
// 1. `?w=1`: `git diff -w` only DROPS whitespace-only hunks; it
|
|
950
|
+
// never rewrites kept hunks. The frontend renderPatch length
|
|
951
|
+
// guard catches the drop case (mismatch between canonical hash
|
|
952
|
+
// count and rendered block count) and bails. So for kept hunks
|
|
953
|
+
// the canonical hash still identifies the right rendered hunk
|
|
954
|
+
// AND matches the persisted summary key — fall back to the
|
|
955
|
+
// canonical diff here for hash computation.
|
|
956
|
+
//
|
|
957
|
+
// 2. `?base=<branch>`: regen produces a DIFFERENT diff against a
|
|
958
|
+
// different base. Hunk counts may match by coincidence, but the
|
|
959
|
+
// content can differ. Hashing the canonical diff would mount a
|
|
960
|
+
// summary onto an override hunk whose code it doesn't describe
|
|
961
|
+
// — silent and wrong. Hash the override diff instead so:
|
|
962
|
+
// - identical-content hunks (hash equals canonical) still
|
|
963
|
+
// match a persisted summary and mount correctly;
|
|
964
|
+
// - divergent-content hunks miss (hash mismatch) and stay
|
|
965
|
+
// unmounted — visibly missing rather than silently wrong.
|
|
966
|
+
let canonicalDiff = diffContent;
|
|
967
|
+
if (hideWhitespace && !baseBranchOverride) {
|
|
968
|
+
const cached = getLocalReviewDiff(reviewId);
|
|
969
|
+
if (cached?.diff) {
|
|
970
|
+
canonicalDiff = cached.diff;
|
|
971
|
+
} else {
|
|
972
|
+
const persisted = await reviewRepo.getLocalDiff(reviewId);
|
|
973
|
+
if (persisted?.diff) canonicalDiff = persisted.diff;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const hunkHashesByFile = {};
|
|
977
|
+
if (canonicalDiff) {
|
|
978
|
+
const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
|
|
979
|
+
for (const [filePath, filePatch] of filePatchMap.entries()) {
|
|
980
|
+
const hunks = parseHunks(filePatch);
|
|
981
|
+
if (hunks.length > 0) {
|
|
982
|
+
hunkHashesByFile[filePath] = hunks.map((h) =>
|
|
983
|
+
hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`)
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
857
989
|
const diffElapsed = Date.now() - tEndpoint;
|
|
858
990
|
if (diffElapsed > 200) {
|
|
859
991
|
logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
|
|
@@ -861,6 +993,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
861
993
|
res.json({
|
|
862
994
|
diff: diffContent || '',
|
|
863
995
|
generated_files: generatedFiles,
|
|
996
|
+
hunk_hashes_by_file: hunkHashesByFile,
|
|
864
997
|
stats: {
|
|
865
998
|
trackedChanges: stats?.trackedChanges || 0,
|
|
866
999
|
untrackedFiles: stats?.untrackedFiles || 0,
|
|
@@ -1325,7 +1458,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
1325
1458
|
|
|
1326
1459
|
const progressCallback = createProgressCallback(analysisId);
|
|
1327
1460
|
|
|
1328
|
-
// Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag)
|
|
1461
|
+
// Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag).
|
|
1462
|
+
// Local mode has no associated GitHub PR, so githubClient is intentionally omitted —
|
|
1463
|
+
// the analyzer drops the GitHub dedup section when no client is supplied.
|
|
1329
1464
|
analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort })
|
|
1330
1465
|
.then(async result => {
|
|
1331
1466
|
logger.section('Local Analysis Results');
|
|
@@ -1605,6 +1740,25 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1605
1740
|
}
|
|
1606
1741
|
});
|
|
1607
1742
|
|
|
1743
|
+
// Re-kick the summary and tour jobs against the fresh diff. Each kickoff
|
|
1744
|
+
// is dedup'd by digest (summaries) or hash (tour); a no-op when the
|
|
1745
|
+
// canonical diff is unchanged (e.g. user clicked refresh but nothing
|
|
1746
|
+
// upstream changed). When the digest IS new, the kickoffs auto-cancel
|
|
1747
|
+
// the stale in-flight job before enqueueing the fresh one — see
|
|
1748
|
+
// kickOffSummaryJob / kickOffTourJob.
|
|
1749
|
+
const config = req.app.get('config') || {};
|
|
1750
|
+
const reviewContext = { prTitle: branchName || review.local_head_branch || undefined };
|
|
1751
|
+
(async () => {
|
|
1752
|
+
await summaryGenerator.kickOffSummaryJob({
|
|
1753
|
+
db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
1754
|
+
});
|
|
1755
|
+
})().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
|
|
1756
|
+
(async () => {
|
|
1757
|
+
await tourGenerator.kickOffTourJob({
|
|
1758
|
+
db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
1759
|
+
});
|
|
1760
|
+
})().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
|
|
1761
|
+
|
|
1608
1762
|
} catch (error) {
|
|
1609
1763
|
logger.error('Error refreshing local diff:', error);
|
|
1610
1764
|
res.status(500).json({
|
|
@@ -1683,7 +1837,29 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1683
1837
|
// branch-ahead commit, making the Branch scope stop selectable.
|
|
1684
1838
|
const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
|
|
1685
1839
|
|
|
1686
|
-
|
|
1840
|
+
res.json({ success: true, action: 'updated', branchAvailable });
|
|
1841
|
+
|
|
1842
|
+
// Re-kick the summary and tour jobs against the freshly-recomputed diff.
|
|
1843
|
+
// The frontend's _resolveHeadChange path applies the refreshed diff in
|
|
1844
|
+
// place via GET /diff (which is read-only and does NOT enqueue), so
|
|
1845
|
+
// without an explicit kickoff here the in-flight stale job from the
|
|
1846
|
+
// previous HEAD would keep burning tokens against a now-stale diff.
|
|
1847
|
+
// Each kickoff is dedup'd by digest/hash; a no-op when the recomputed
|
|
1848
|
+
// diff matches. When the digest IS new, the kickoffs auto-cancel the
|
|
1849
|
+
// stale in-flight job before enqueueing the fresh one.
|
|
1850
|
+
const config = req.app.get('config') || {};
|
|
1851
|
+
const reviewContext = { prTitle: headBranch || review.local_head_branch || undefined };
|
|
1852
|
+
(async () => {
|
|
1853
|
+
await summaryGenerator.kickOffSummaryJob({
|
|
1854
|
+
db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
1855
|
+
});
|
|
1856
|
+
})().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
|
|
1857
|
+
(async () => {
|
|
1858
|
+
await tourGenerator.kickOffTourJob({
|
|
1859
|
+
db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
1860
|
+
});
|
|
1861
|
+
})().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
|
|
1862
|
+
return;
|
|
1687
1863
|
}
|
|
1688
1864
|
|
|
1689
1865
|
// action === 'new-session'
|
|
@@ -1774,11 +1950,16 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
|
1774
1950
|
} else {
|
|
1775
1951
|
const { detectBaseBranch } = require('../git/base-branch');
|
|
1776
1952
|
const config = req.app.get('config') || {};
|
|
1777
|
-
const
|
|
1953
|
+
const { resolveHostBinding: _resolveHostBinding } = require('../config');
|
|
1954
|
+
const localBinding = _resolveHostBinding(review.repository, config);
|
|
1955
|
+
const token = localBinding.token;
|
|
1778
1956
|
const detection = await detectBaseBranch(localPath, currentBranch, {
|
|
1779
1957
|
repository: review.repository,
|
|
1780
1958
|
enableGraphite: config.enable_graphite === true,
|
|
1781
|
-
_deps: token ? {
|
|
1959
|
+
_deps: token ? {
|
|
1960
|
+
getGitHubToken: () => token,
|
|
1961
|
+
getHostBinding: () => localBinding
|
|
1962
|
+
} : undefined
|
|
1782
1963
|
});
|
|
1783
1964
|
if (!detection) {
|
|
1784
1965
|
return res.status(400).json({ error: 'Could not detect base branch' });
|
|
@@ -1839,6 +2020,23 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
|
1839
2020
|
}
|
|
1840
2021
|
});
|
|
1841
2022
|
|
|
2023
|
+
// Re-kick the summary and tour jobs against the freshly-scoped diff.
|
|
2024
|
+
// Each kickoff is dedup'd by diff digest/hash; when the scope change
|
|
2025
|
+
// actually produces a different diff, the kickoffs auto-cancel the
|
|
2026
|
+
// stale in-flight job before enqueueing the fresh one.
|
|
2027
|
+
const config = req.app.get('config') || {};
|
|
2028
|
+
const reviewContext = { prTitle: currentBranch || review.local_head_branch || undefined };
|
|
2029
|
+
(async () => {
|
|
2030
|
+
await summaryGenerator.kickOffSummaryJob({
|
|
2031
|
+
db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
2032
|
+
});
|
|
2033
|
+
})().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
|
|
2034
|
+
(async () => {
|
|
2035
|
+
await tourGenerator.kickOffTourJob({
|
|
2036
|
+
db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
|
|
2037
|
+
});
|
|
2038
|
+
})().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
|
|
2039
|
+
|
|
1842
2040
|
} catch (error) {
|
|
1843
2041
|
logger.error(`Error setting scope: ${error.message}`);
|
|
1844
2042
|
res.status(500).json({ error: 'Failed to set scope: ' + error.message });
|
|
@@ -2074,11 +2272,19 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2074
2272
|
baseBranch: review.local_base_branch || null,
|
|
2075
2273
|
});
|
|
2076
2274
|
|
|
2077
|
-
// Generate and cache diff
|
|
2275
|
+
// Generate and cache diff. Hoist the result out of the try so we can also
|
|
2276
|
+
// persist it to `local_diffs` below (after reviewRepo is constructed) — the
|
|
2277
|
+
// council path previously cached the diff in-memory only, which left the
|
|
2278
|
+
// manual tour/summary buttons reporting a false "no-diff" after a restart.
|
|
2279
|
+
let councilDiff = null;
|
|
2280
|
+
let councilStats = null;
|
|
2281
|
+
let councilDigest = null;
|
|
2078
2282
|
try {
|
|
2079
2283
|
const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
|
|
2080
|
-
|
|
2081
|
-
|
|
2284
|
+
councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
|
|
2285
|
+
councilDiff = diffResult.diff;
|
|
2286
|
+
councilStats = diffResult.stats;
|
|
2287
|
+
setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
2082
2288
|
} catch (diffError) {
|
|
2083
2289
|
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
2084
2290
|
}
|
|
@@ -2086,6 +2292,16 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2086
2292
|
// Resolve instructions
|
|
2087
2293
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
2088
2294
|
const reviewRepo = new ReviewRepository(db);
|
|
2295
|
+
|
|
2296
|
+
// Durably persist the diff so it survives a restart and the manual
|
|
2297
|
+
// tour/summary buttons can find it (parity with the analysis-push path).
|
|
2298
|
+
if (councilDiff) {
|
|
2299
|
+
try {
|
|
2300
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
2301
|
+
} catch (saveError) {
|
|
2302
|
+
logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2089
2305
|
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
2090
2306
|
const repoInstructions = repoSettings?.default_instructions || null;
|
|
2091
2307
|
const requestInstructions = rawInstructions?.trim() || null;
|
|
@@ -2103,6 +2319,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2103
2319
|
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
2104
2320
|
buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
|
|
2105
2321
|
|
|
2322
|
+
// Local mode has no associated GitHub PR, so we do not pass a githubClient.
|
|
2323
|
+
// The analyzer drops the GitHub dedup section when no client is supplied.
|
|
2106
2324
|
const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
|
|
2107
2325
|
db,
|
|
2108
2326
|
{
|
|
@@ -2144,4 +2362,183 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2144
2362
|
}
|
|
2145
2363
|
});
|
|
2146
2364
|
|
|
2365
|
+
/**
|
|
2366
|
+
* POST /api/local/:reviewId/jobs/:jobKey/start
|
|
2367
|
+
*
|
|
2368
|
+
* Manually trigger a summary or tour generation job for this local review.
|
|
2369
|
+
* Used by the frontend when `auto_generate` is off and the user clicks the
|
|
2370
|
+
* toolbar button.
|
|
2371
|
+
*
|
|
2372
|
+
* Mirrors the server-side kickoff that runs on local review load, but passes
|
|
2373
|
+
* `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
|
|
2374
|
+
* gate still applies — disabled features return 409).
|
|
2375
|
+
*
|
|
2376
|
+
* Request:
|
|
2377
|
+
* - `jobKey` path param: `summary` or `tour`
|
|
2378
|
+
*
|
|
2379
|
+
* Responses:
|
|
2380
|
+
* - 200 `{ started: true, alreadyRunning: false }` — enqueued
|
|
2381
|
+
* - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
|
|
2382
|
+
* is already in flight
|
|
2383
|
+
* (idempotent no-op)
|
|
2384
|
+
* - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
|
|
2385
|
+
* - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
|
|
2386
|
+
* - 404 `{ error: '...' }` — review not found
|
|
2387
|
+
* - 409 `{ error: '... disabled' }` — feature disabled in config
|
|
2388
|
+
*/
|
|
2389
|
+
const LOCAL_MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
|
|
2390
|
+
|
|
2391
|
+
router.post('/api/local/:reviewId/jobs/:jobKey/start', async (req, res) => {
|
|
2392
|
+
try {
|
|
2393
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
2394
|
+
if (!Number.isInteger(reviewId) || reviewId <= 0) {
|
|
2395
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
2396
|
+
}
|
|
2397
|
+
const { jobKey } = req.params;
|
|
2398
|
+
if (!LOCAL_MANUAL_START_JOB_KEYS.has(jobKey)) {
|
|
2399
|
+
return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const db = req.app.get('db');
|
|
2403
|
+
const config = req.app.get('config') || {};
|
|
2404
|
+
|
|
2405
|
+
if (jobKey === 'summary' && !getSummaryEnabled(config)) {
|
|
2406
|
+
return res.status(409).json({ error: 'Summaries feature is disabled in config' });
|
|
2407
|
+
}
|
|
2408
|
+
if (jobKey === 'tour' && !getTourEnabled(config)) {
|
|
2409
|
+
return res.status(409).json({ error: 'Tours feature is disabled in config' });
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const reviewRepo = new ReviewRepository(db);
|
|
2413
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
2414
|
+
if (!review) {
|
|
2415
|
+
return res.status(404).json({ error: `Local review #${reviewId} not found` });
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
const worktreePath = review.local_path || null;
|
|
2419
|
+
|
|
2420
|
+
// Resolve the diff through the same chain the rest of this file uses, rather
|
|
2421
|
+
// than a DB-only read. Reviews created via the analysis-push, council, or MCP
|
|
2422
|
+
// paths may have a diff only in the in-memory cache (or nowhere yet), so a
|
|
2423
|
+
// DB-only read would falsely report "no-diff" for a review that clearly has
|
|
2424
|
+
// changes. Order: (1) in-memory cache, (2) persisted `local_diffs` row,
|
|
2425
|
+
// (3) regenerate from the live working tree (scope-aware) and persist.
|
|
2426
|
+
let diffText = getLocalReviewDiff(reviewId)?.diff || '';
|
|
2427
|
+
|
|
2428
|
+
if (!diffText) {
|
|
2429
|
+
const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
|
|
2430
|
+
diffText = persistedDiff?.diff || '';
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
if (!diffText && worktreePath) {
|
|
2434
|
+
// Regenerate from the current working tree and persist (in-memory + DB) so
|
|
2435
|
+
// the next read is fast and durable, and so pre-Fix-B reviews self-heal.
|
|
2436
|
+
// Mirrors the council diff block above: on error, log and leave it empty.
|
|
2437
|
+
try {
|
|
2438
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
2439
|
+
const hasBranch = includesBranch(scopeStart);
|
|
2440
|
+
|
|
2441
|
+
// Snapshot guard: mirror the HEAD invariant enforced by the refresh-diff
|
|
2442
|
+
// handler (see ~line 1702). For a non-branch review, the persisted diff is
|
|
2443
|
+
// pinned to `local_head_sha`. If HEAD has since moved, regenerating here
|
|
2444
|
+
// would silently re-snapshot the CURRENT worktree onto a row that still
|
|
2445
|
+
// claims the OLDER SHA — a data-consistency hole. So we only regenerate
|
|
2446
|
+
// when HEAD still matches; otherwise we leave diffText empty and let the
|
|
2447
|
+
// `{ started: false, reason: 'no-diff' }` response funnel the user through
|
|
2448
|
+
// the established refresh-diff / resolve-head-change flow. Branch-scoped
|
|
2449
|
+
// reviews persist across HEAD changes, so they always regenerate.
|
|
2450
|
+
let headPinned = true;
|
|
2451
|
+
if (!hasBranch && review.local_head_sha) {
|
|
2452
|
+
// Lazy require keeps getHeadSha stubbable via vi.spyOn in tests.
|
|
2453
|
+
const { getHeadSha } = require('../local-review');
|
|
2454
|
+
const currentHeadSha = await getHeadSha(worktreePath);
|
|
2455
|
+
if (currentHeadSha !== review.local_head_sha) {
|
|
2456
|
+
headPinned = false;
|
|
2457
|
+
logger.warn(`Skipping self-heal diff regen for local review ${reviewId} (${jobKey}): HEAD moved on non-branch review (recorded ${review.local_head_sha}, current ${currentHeadSha}) — funneling through resolve-head-change`);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
if (headPinned) {
|
|
2462
|
+
const diffResult = await generateScopedDiff(worktreePath, scopeStart, scopeEnd, review.local_base_branch);
|
|
2463
|
+
diffText = diffResult.diff || '';
|
|
2464
|
+
if (diffText) {
|
|
2465
|
+
const digest = await computeScopedDigest(worktreePath, scopeStart, scopeEnd);
|
|
2466
|
+
setLocalReviewDiff(reviewId, { diff: diffText, stats: diffResult.stats, digest });
|
|
2467
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: diffText, stats: diffResult.stats, digest });
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
} catch (regenError) {
|
|
2471
|
+
// A getHeadSha throw (e.g. missing worktree) lands here: leave diffText
|
|
2472
|
+
// empty so the no-diff response fires, matching prior behavior.
|
|
2473
|
+
logger.warn(`Could not regenerate diff for local review ${reviewId} manual ${jobKey} start: ${regenError.message}`);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (!diffText || !worktreePath) {
|
|
2478
|
+
return res.json({ started: false, reason: 'no-diff' });
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
|
|
2482
|
+
? backgroundQueue.findActiveJobType(reviewId, jobKey === 'summary' ? 'summaries' : 'tour')
|
|
2483
|
+
: null;
|
|
2484
|
+
if (activeJobType) {
|
|
2485
|
+
return res.json({ started: false, alreadyRunning: true });
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
const reviewContext = {
|
|
2489
|
+
prTitle: review.name || review.local_head_branch || undefined
|
|
2490
|
+
};
|
|
2491
|
+
|
|
2492
|
+
if (jobKey === 'summary') {
|
|
2493
|
+
Promise.resolve(summaryGenerator.kickOffSummaryJob({
|
|
2494
|
+
db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
|
|
2495
|
+
})).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${reviewId}: ${err.message}`));
|
|
2496
|
+
} else {
|
|
2497
|
+
Promise.resolve(tourGenerator.kickOffTourJob({
|
|
2498
|
+
db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
|
|
2499
|
+
})).catch((err) => logger.warn(`Manual tour kickoff failed for review ${reviewId}: ${err.message}`));
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
return res.json({ started: true, alreadyRunning: false });
|
|
2503
|
+
} catch (error) {
|
|
2504
|
+
logger.error(`Error starting manual job for local review: ${error.message}`);
|
|
2505
|
+
res.status(500).json({ error: 'Failed to start job: ' + error.message });
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* POST /api/local/:reviewId/jobs/:jobKey/cancel
|
|
2511
|
+
*
|
|
2512
|
+
* Local-mode wrapper around the shared cancel handler in reviews.js.
|
|
2513
|
+
* The unified `/api/reviews/:reviewId/jobs/:jobKey/cancel` already works
|
|
2514
|
+
* for local reviews (both modes share the `reviews` table), but exposing
|
|
2515
|
+
* it under both prefixes lets the frontend pick whichever helper matches
|
|
2516
|
+
* its current mode without a special case. See `handleJobCancel` in
|
|
2517
|
+
* `src/routes/reviews.js` for the canonical implementation.
|
|
2518
|
+
*/
|
|
2519
|
+
router.post('/api/local/:reviewId/jobs/:jobKey/cancel', async (req, res) => {
|
|
2520
|
+
try {
|
|
2521
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
2522
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
2523
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
2524
|
+
}
|
|
2525
|
+
const db = req.app.get('db');
|
|
2526
|
+
// Same shape that validateReviewId attaches — we re-derive here because
|
|
2527
|
+
// local routes don't pass through that middleware by convention.
|
|
2528
|
+
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [reviewId]);
|
|
2529
|
+
if (!review) {
|
|
2530
|
+
return res.status(404).json({ error: `Review #${reviewId} not found` });
|
|
2531
|
+
}
|
|
2532
|
+
req.reviewId = reviewId;
|
|
2533
|
+
req.review = review;
|
|
2534
|
+
// await (not return) so any rejection from the delegated handler is
|
|
2535
|
+
// caught by the outer try/catch — Express 4 does not forward rejected
|
|
2536
|
+
// promises from async route handlers.
|
|
2537
|
+
await reviewsRouter.handleJobCancel(req, res);
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
logger.error(`Error cancelling background job for local review: ${error.message}`);
|
|
2540
|
+
res.status(500).json({ error: 'Failed to cancel background job' });
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2147
2544
|
module.exports = router;
|
package/src/routes/mcp.js
CHANGED
|
@@ -11,19 +11,22 @@ const { getTierForModel } = require('../ai/provider');
|
|
|
11
11
|
const { TIERS, TIER_ALIASES, resolveTier } = require('../ai/prompts/config');
|
|
12
12
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
13
13
|
const path = require('path');
|
|
14
|
-
const { getCurrentBranch } = require('../local-review');
|
|
14
|
+
const { getCurrentBranch, generateScopedDiff, computeScopedDigest } = require('../local-review');
|
|
15
|
+
const { reviewScope } = require('../local-scope');
|
|
15
16
|
const { normalizeRepository } = require('../utils/paths');
|
|
16
17
|
const logger = require('../utils/logger');
|
|
17
18
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
18
19
|
const {
|
|
19
20
|
activeAnalyses,
|
|
20
21
|
reviewToAnalysisId,
|
|
22
|
+
localReviewDiffs,
|
|
21
23
|
determineCompletionInfo,
|
|
22
24
|
broadcastProgress,
|
|
23
25
|
createProgressCallback
|
|
24
26
|
} = require('./shared');
|
|
25
27
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
26
|
-
const { resolveLoadSkills } = require('../config');
|
|
28
|
+
const { resolveLoadSkills, resolveHostBinding } = require('../config');
|
|
29
|
+
const { GitHubClient } = require('../github/client');
|
|
27
30
|
|
|
28
31
|
// All valid tier values: canonical tiers + aliases (for Zod enum validation)
|
|
29
32
|
const ALL_TIER_VALUES = /** @type {[string, ...string[]]} */ ([...TIERS, ...Object.keys(TIER_ALIASES)]);
|
|
@@ -557,6 +560,32 @@ function createMCPServer(db, options = {}) {
|
|
|
557
560
|
|
|
558
561
|
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
559
562
|
|
|
563
|
+
// Persist the diff so the web UI can display it and the manual
|
|
564
|
+
// tour/summary buttons work (including after a restart). The MCP path
|
|
565
|
+
// previously stored the diff only in analysis_runs, leaving no
|
|
566
|
+
// `local_diffs` row.
|
|
567
|
+
//
|
|
568
|
+
// Use the review's recorded scope — not the default-scope wrapper — so
|
|
569
|
+
// re-using a review that was moved to `staged` or `branch` scope does
|
|
570
|
+
// not clobber its durable row with a narrower default-scope patch. Set
|
|
571
|
+
// the in-memory cache alongside the DB row so the cache-first readers
|
|
572
|
+
// in routes/local.js don't keep serving a stale entry. Non-fatal:
|
|
573
|
+
// matches the analysis-push path.
|
|
574
|
+
try {
|
|
575
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
576
|
+
const diffResult = await generateScopedDiff(
|
|
577
|
+
localPath,
|
|
578
|
+
scopeStart,
|
|
579
|
+
scopeEnd,
|
|
580
|
+
review.local_base_branch || null
|
|
581
|
+
);
|
|
582
|
+
const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
583
|
+
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
584
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
585
|
+
} catch (diffError) {
|
|
586
|
+
logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
560
589
|
// Resolve provider and model
|
|
561
590
|
const repoSettings = repository ? await repoSettingsRepo.getRepoSettings(repository) : null;
|
|
562
591
|
const provider = process.env.PAIR_REVIEW_PROVIDER || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
|
|
@@ -640,7 +669,9 @@ function createMCPServer(db, options = {}) {
|
|
|
640
669
|
headSha: localHeadSha
|
|
641
670
|
});
|
|
642
671
|
|
|
643
|
-
// Launch analysis asynchronously (skipRunCreation since we created the record above)
|
|
672
|
+
// Launch analysis asynchronously (skipRunCreation since we created the record above).
|
|
673
|
+
// Local mode has no associated GitHub PR, so githubClient is intentionally
|
|
674
|
+
// omitted — the analyzer drops the GitHub dedup section when no client is supplied.
|
|
644
675
|
analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247) })
|
|
645
676
|
.then(result => handleAnalysisCompletion(analysisId, runId, result, async (r) => {
|
|
646
677
|
if (r.summary) {
|
|
@@ -762,6 +793,18 @@ function createMCPServer(db, options = {}) {
|
|
|
762
793
|
const progressCallback = createProgressCallback(analysisId);
|
|
763
794
|
const tier = resolveTier(args.tier);
|
|
764
795
|
|
|
796
|
+
// Build a GitHubClient so the analyzer can pre-fetch existing PR
|
|
797
|
+
// review comments when args.excludePrevious.github is set. Uses
|
|
798
|
+
// the repo's binding so alt-host repos route to the right host
|
|
799
|
+
// with the right token.
|
|
800
|
+
const prAnalysisBinding = resolveHostBinding(repository, config);
|
|
801
|
+
const prAnalysisGithubClient = prAnalysisBinding.token
|
|
802
|
+
? new GitHubClient(prAnalysisBinding)
|
|
803
|
+
: undefined;
|
|
804
|
+
if (prAnalysisGithubClient) {
|
|
805
|
+
logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (mcp)`);
|
|
806
|
+
}
|
|
807
|
+
|
|
765
808
|
logger.log('MCP', `Starting PR analysis: PR #${prNumber} in ${repository}, runId=${runId}`, 'magenta');
|
|
766
809
|
|
|
767
810
|
// Create DB analysis_runs record just before launching so it's queryable for polling
|
|
@@ -779,7 +822,7 @@ function createMCPServer(db, options = {}) {
|
|
|
779
822
|
});
|
|
780
823
|
|
|
781
824
|
// Launch analysis asynchronously (skipRunCreation since we created the record above)
|
|
782
|
-
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247) })
|
|
825
|
+
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247), githubClient: prAnalysisGithubClient })
|
|
783
826
|
.then(result => handleAnalysisCompletion(analysisId, runId, result, async (r) => {
|
|
784
827
|
try { await prMetadataRepo.updateLastAiRunId(prMetadata.id, r.runId); } catch (_) { /* ignore */ }
|
|
785
828
|
if (r.summary) {
|