@in-the-loop-labs/pair-review 3.0.5 → 3.1.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/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
- package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
- package/public/css/analysis-config.css +83 -0
- package/public/css/pr.css +191 -4
- package/public/index.html +20 -0
- package/public/js/components/AIPanel.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +83 -8
- package/public/js/components/AnalysisConfigModal.js +155 -5
- package/public/js/components/ChatPanel.js +22 -5
- package/public/js/components/CouncilProgressModal.js +239 -22
- package/public/js/components/TimeoutSelect.js +2 -0
- package/public/js/components/VoiceCentricConfigTab.js +179 -12
- package/public/js/index.js +119 -1
- package/public/js/local.js +141 -47
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +71 -12
- package/public/js/repo-settings.js +2 -2
- package/public/local.html +32 -11
- package/public/pr.html +2 -0
- package/src/ai/analyzer.js +371 -111
- package/src/ai/claude-provider.js +2 -0
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +2 -0
- package/src/ai/executable-provider.js +534 -0
- package/src/ai/gemini-provider.js +2 -0
- package/src/ai/index.js +9 -1
- package/src/ai/pi-provider.js +10 -8
- package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
- package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
- package/src/ai/prompts/baseline/level1/balanced.js +12 -0
- package/src/ai/prompts/baseline/level1/fast.js +11 -0
- package/src/ai/prompts/baseline/level1/thorough.js +12 -0
- package/src/ai/prompts/baseline/level2/balanced.js +13 -0
- package/src/ai/prompts/baseline/level2/fast.js +12 -0
- package/src/ai/prompts/baseline/level2/thorough.js +13 -0
- package/src/ai/prompts/baseline/level3/balanced.js +13 -0
- package/src/ai/prompts/baseline/level3/fast.js +12 -0
- package/src/ai/prompts/baseline/level3/thorough.js +13 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
- package/src/ai/prompts/render-for-skill.js +3 -0
- package/src/ai/prompts/shared/output-schema.js +8 -0
- package/src/ai/provider.js +89 -4
- package/src/chat/prompt-builder.js +17 -1
- package/src/chat/session-manager.js +32 -28
- package/src/config.js +15 -2
- package/src/database.js +59 -15
- package/src/git/base-branch.js +133 -52
- package/src/local-review.js +15 -9
- package/src/main.js +3 -2
- package/src/routes/analyses.js +34 -8
- package/src/routes/chat.js +15 -8
- package/src/routes/config.js +3 -120
- package/src/routes/councils.js +15 -6
- package/src/routes/executable-analysis.js +494 -0
- package/src/routes/local.js +160 -26
- package/src/routes/mcp.js +9 -4
- package/src/routes/pr.js +166 -29
- package/src/routes/reviews.js +31 -5
- package/src/routes/shared.js +72 -5
- package/src/routes/worktrees.js +4 -2
- package/src/utils/comment-formatter.js +28 -11
- package/src/utils/instructions.js +22 -8
- package/src/utils/logger.js +20 -10
package/src/routes/pr.js
CHANGED
|
@@ -32,7 +32,10 @@ const { broadcastReviewEvent } = require('../events/review-events');
|
|
|
32
32
|
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
33
33
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
|
|
34
34
|
const simpleGit = require('simple-git');
|
|
35
|
+
const { execSync } = require('child_process');
|
|
36
|
+
const { readFileSync } = require('fs');
|
|
35
37
|
const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
|
|
38
|
+
const { tryGraphiteState, readGraphitePRInfo, enrichStackWithPRInfo } = require('../git/base-branch');
|
|
36
39
|
const {
|
|
37
40
|
activeAnalyses,
|
|
38
41
|
reviewToAnalysisId,
|
|
@@ -40,11 +43,15 @@ const {
|
|
|
40
43
|
determineCompletionInfo,
|
|
41
44
|
broadcastProgress,
|
|
42
45
|
createProgressCallback,
|
|
43
|
-
parseEnabledLevels
|
|
46
|
+
parseEnabledLevels,
|
|
47
|
+
registerProcess: registerProcessForCancellation
|
|
44
48
|
} = require('./shared');
|
|
45
49
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
46
50
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
47
51
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
52
|
+
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
53
|
+
const { CommentRepository } = require('../database');
|
|
54
|
+
const { runExecutableAnalysis } = require('./executable-analysis');
|
|
48
55
|
const analysesRouter = require('./analyses');
|
|
49
56
|
|
|
50
57
|
const router = express.Router();
|
|
@@ -229,6 +236,25 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
229
236
|
? getShaAbbrevLength(extendedData.worktree_path)
|
|
230
237
|
: DEFAULT_SHA_ABBREV_LENGTH;
|
|
231
238
|
|
|
239
|
+
// Detect Graphite stack if enabled
|
|
240
|
+
let stackData = null;
|
|
241
|
+
{
|
|
242
|
+
const stackConfig = req.app.get('config') || {};
|
|
243
|
+
if (stackConfig.enable_graphite === true && extendedData.worktree_path && prMetadata.head_branch) {
|
|
244
|
+
try {
|
|
245
|
+
const graphiteResult = tryGraphiteState(extendedData.worktree_path, prMetadata.head_branch, { execSync, readFileSync });
|
|
246
|
+
if (graphiteResult?.stack) {
|
|
247
|
+
const prInfo = readGraphitePRInfo(extendedData.worktree_path, { execSync, readFileSync });
|
|
248
|
+
stackData = prInfo?.prInfos
|
|
249
|
+
? enrichStackWithPRInfo(graphiteResult.stack, prInfo.prInfos)
|
|
250
|
+
: graphiteResult.stack;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Non-fatal — stack detection is an enhancement
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
232
258
|
// Prepare response
|
|
233
259
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
234
260
|
const response = {
|
|
@@ -246,6 +272,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
246
272
|
head_branch: prMetadata.head_branch,
|
|
247
273
|
head_sha: extendedData.head_sha || null, // Head commit SHA for GitHub API comments
|
|
248
274
|
node_id: extendedData.node_id || null, // GraphQL node ID for review submission
|
|
275
|
+
stack_data: stackData,
|
|
249
276
|
shaAbbrevLength,
|
|
250
277
|
created_at: prMetadata.created_at,
|
|
251
278
|
updated_at: prMetadata.updated_at,
|
|
@@ -406,6 +433,22 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
406
433
|
const parsedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
|
|
407
434
|
const [repoOwner, repoName] = repository.split('/');
|
|
408
435
|
|
|
436
|
+
// Detect Graphite stack if enabled
|
|
437
|
+
let stackData = null;
|
|
438
|
+
if (config.enable_graphite === true && worktreePath && prData.head_branch) {
|
|
439
|
+
try {
|
|
440
|
+
const graphiteResult = tryGraphiteState(worktreePath, prData.head_branch, { execSync, readFileSync });
|
|
441
|
+
if (graphiteResult?.stack) {
|
|
442
|
+
const prInfo = readGraphitePRInfo(worktreePath, { execSync, readFileSync });
|
|
443
|
+
stackData = prInfo?.prInfos
|
|
444
|
+
? enrichStackWithPRInfo(graphiteResult.stack, prInfo.prInfos)
|
|
445
|
+
: graphiteResult.stack;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
// Non-fatal — stack detection is an enhancement
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
409
452
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
410
453
|
const response = {
|
|
411
454
|
success: true,
|
|
@@ -420,6 +463,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
420
463
|
state: parsedData.state || 'open',
|
|
421
464
|
base_branch: prMetadata.base_branch,
|
|
422
465
|
head_branch: prMetadata.head_branch,
|
|
466
|
+
stack_data: stackData,
|
|
423
467
|
created_at: prMetadata.created_at,
|
|
424
468
|
updated_at: prMetadata.updated_at,
|
|
425
469
|
file_changes: parsedData.changed_files ? parsedData.changed_files.length : 0,
|
|
@@ -692,7 +736,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
692
736
|
const worktreeRepo = new WorktreeRepository(db);
|
|
693
737
|
const worktreeRecord = await worktreeRepo.findByPR(prNumber, repository);
|
|
694
738
|
|
|
695
|
-
// When ?w=1, regenerate the diff from the worktree with whitespace
|
|
739
|
+
// When ?w=1, regenerate the diff from the worktree with whitespace hidden
|
|
696
740
|
const hideWhitespace = req.query.w === '1';
|
|
697
741
|
let diffContent = prData.diff || '';
|
|
698
742
|
let changedFiles = prData.changed_files || [];
|
|
@@ -702,24 +746,16 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
702
746
|
const worktreePath = worktreeRecord.path;
|
|
703
747
|
await fs.access(worktreePath);
|
|
704
748
|
const git = simpleGit(worktreePath);
|
|
749
|
+
|
|
705
750
|
const baseSha = prData.base_sha;
|
|
706
751
|
const headSha = prData.head_sha;
|
|
707
752
|
|
|
708
753
|
if (baseSha && headSha) {
|
|
709
|
-
|
|
710
|
-
diffContent = await git.diff(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
...GIT_DIFF_FLAGS_ARRAY
|
|
715
|
-
]);
|
|
716
|
-
|
|
717
|
-
// Regenerate changed files stats with -w flag
|
|
718
|
-
const diffSummary = await git.diffSummary([
|
|
719
|
-
`${baseSha}...${headSha}`,
|
|
720
|
-
'-w',
|
|
721
|
-
...GIT_DIFF_SUMMARY_FLAGS_ARRAY
|
|
722
|
-
]);
|
|
754
|
+
const diffArgs = [`${baseSha}...${headSha}`, '--unified=3', ...GIT_DIFF_FLAGS_ARRAY, '-w'];
|
|
755
|
+
diffContent = await git.diff(diffArgs);
|
|
756
|
+
|
|
757
|
+
const summaryArgs = [`${baseSha}...${headSha}`, ...GIT_DIFF_SUMMARY_FLAGS_ARRAY, '-w'];
|
|
758
|
+
const diffSummary = await git.diffSummary(summaryArgs);
|
|
723
759
|
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
724
760
|
changedFiles = diffSummary.files.map(file => {
|
|
725
761
|
const resolvedFile = resolveRenamedFile(file.file);
|
|
@@ -739,7 +775,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
739
775
|
});
|
|
740
776
|
}
|
|
741
777
|
} catch (wsError) {
|
|
742
|
-
logger.warn(`Could not generate
|
|
778
|
+
logger.warn(`Could not generate diff for PR #${prNumber}: ${wsError.message}`);
|
|
743
779
|
// Fall back to cached diff (diffContent and changedFiles already set from prData)
|
|
744
780
|
}
|
|
745
781
|
} else if (worktreeRecord && worktreeRecord.path) {
|
|
@@ -756,9 +792,8 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
756
792
|
}
|
|
757
793
|
}
|
|
758
794
|
|
|
759
|
-
// When
|
|
760
|
-
//
|
|
761
|
-
// stale cached values from prData.
|
|
795
|
+
// When diff was regenerated (whitespace), compute aggregate stats from
|
|
796
|
+
// the regenerated changedFiles instead of using stale cached values from prData.
|
|
762
797
|
const additions = hideWhitespace
|
|
763
798
|
? changedFiles.reduce((sum, f) => sum + (f.insertions || 0), 0)
|
|
764
799
|
: (prData.additions || 0);
|
|
@@ -1459,6 +1494,79 @@ router.post('/api/parse-pr-url', (req, res) => {
|
|
|
1459
1494
|
// PR Analysis Routes
|
|
1460
1495
|
// ==========================================================================
|
|
1461
1496
|
|
|
1497
|
+
/**
|
|
1498
|
+
* Handle analysis for executable providers in PR mode.
|
|
1499
|
+
* Spawns the external CLI against the PR worktree and maps output to suggestions.
|
|
1500
|
+
*/
|
|
1501
|
+
async function handleExecutablePRAnalysis(req, res, {
|
|
1502
|
+
reviewId, review, prNumber, owner, repo, repository, worktreePath, prMetadata,
|
|
1503
|
+
selectedProvider, selectedModel, repoInstructions, requestInstructions,
|
|
1504
|
+
combinedInstructions, runId, analysisId, reviewRepo
|
|
1505
|
+
}) {
|
|
1506
|
+
const prContext = {
|
|
1507
|
+
number: prNumber, owner, repo,
|
|
1508
|
+
author: prMetadata.author, baseBranch: prMetadata.base_branch, headBranch: prMetadata.head_branch,
|
|
1509
|
+
baseSha: prMetadata.base_sha || null, headSha: prMetadata.head_sha || null,
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
return runExecutableAnalysis(req, res, {
|
|
1513
|
+
reviewId,
|
|
1514
|
+
review,
|
|
1515
|
+
selectedProvider,
|
|
1516
|
+
selectedModel,
|
|
1517
|
+
repoInstructions,
|
|
1518
|
+
requestInstructions,
|
|
1519
|
+
runId,
|
|
1520
|
+
analysisId,
|
|
1521
|
+
repository,
|
|
1522
|
+
reviewType: 'pr',
|
|
1523
|
+
headSha: prMetadata.head_sha,
|
|
1524
|
+
extraInitialStatus: { prNumber }
|
|
1525
|
+
}, {
|
|
1526
|
+
activeAnalyses,
|
|
1527
|
+
reviewToAnalysisId,
|
|
1528
|
+
broadcastProgress,
|
|
1529
|
+
broadcastReviewEvent,
|
|
1530
|
+
registerProcessForCancellation
|
|
1531
|
+
}, {
|
|
1532
|
+
logLabel: `PR #${prNumber}`,
|
|
1533
|
+
buildContext: (_r, { selectedModel: model, requestInstructions: customInstructions }) => ({
|
|
1534
|
+
title: prMetadata.title || `PR #${prNumber}`,
|
|
1535
|
+
description: prMetadata.description || '',
|
|
1536
|
+
cwd: worktreePath,
|
|
1537
|
+
model,
|
|
1538
|
+
baseSha: prMetadata.base_sha || null,
|
|
1539
|
+
headSha: prMetadata.head_sha || null,
|
|
1540
|
+
baseBranch: prMetadata.base_branch || null,
|
|
1541
|
+
headBranch: prMetadata.head_branch || null,
|
|
1542
|
+
customInstructions: customInstructions || null
|
|
1543
|
+
}),
|
|
1544
|
+
buildHookPayload: () => ({
|
|
1545
|
+
mode: 'pr',
|
|
1546
|
+
prContext
|
|
1547
|
+
}),
|
|
1548
|
+
onSuccess: async (db, _runId, { summary }) => {
|
|
1549
|
+
// Update pr_metadata with last_ai_run_id
|
|
1550
|
+
const { PRMetadataRepository } = require('../database');
|
|
1551
|
+
const prMetadataRepo = new PRMetadataRepository(db);
|
|
1552
|
+
try {
|
|
1553
|
+
await prMetadataRepo.updateLastAiRunId(prMetadata.id, _runId);
|
|
1554
|
+
} catch (e) {
|
|
1555
|
+
logger.warn(`Failed to update pr_metadata: ${e.message}`);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Save summary
|
|
1559
|
+
if (summary) {
|
|
1560
|
+
try {
|
|
1561
|
+
await reviewRepo.upsertSummary(prNumber, repository, summary);
|
|
1562
|
+
} catch (e) {
|
|
1563
|
+
logger.warn(`Failed to save summary: ${e.message}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1462
1570
|
/**
|
|
1463
1571
|
* Trigger AI analysis for a PR
|
|
1464
1572
|
*/
|
|
@@ -1467,7 +1575,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1467
1575
|
const { owner, repo, number } = req.params;
|
|
1468
1576
|
const prNumber = parseInt(number);
|
|
1469
1577
|
|
|
1470
|
-
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
|
|
1578
|
+
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels, excludePrevious } = req.body || {};
|
|
1471
1579
|
|
|
1472
1580
|
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
1473
1581
|
let requestInstructions = rawInstructions?.trim() || null;
|
|
@@ -1511,6 +1619,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1511
1619
|
});
|
|
1512
1620
|
}
|
|
1513
1621
|
|
|
1622
|
+
const appConfig = req.app.get('config') || {};
|
|
1623
|
+
const globalInstructions = appConfig.globalInstructions || null;
|
|
1624
|
+
|
|
1514
1625
|
const { provider, model, repoInstructions, combinedInstructions } = await withTransaction(db, async () => {
|
|
1515
1626
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1516
1627
|
const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
@@ -1521,8 +1632,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1521
1632
|
} else if (fetchedRepoSettings && fetchedRepoSettings.default_provider) {
|
|
1522
1633
|
selectedProvider = fetchedRepoSettings.default_provider;
|
|
1523
1634
|
} else {
|
|
1524
|
-
|
|
1525
|
-
selectedProvider = config.default_provider || config.provider || 'claude';
|
|
1635
|
+
selectedProvider = appConfig.default_provider || appConfig.provider || 'claude';
|
|
1526
1636
|
}
|
|
1527
1637
|
|
|
1528
1638
|
let selectedModel;
|
|
@@ -1535,7 +1645,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1535
1645
|
}
|
|
1536
1646
|
|
|
1537
1647
|
const fetchedRepoInstructions = fetchedRepoSettings?.default_instructions || null;
|
|
1538
|
-
const mergedInstructions = mergeInstructions(fetchedRepoInstructions, requestInstructions);
|
|
1648
|
+
const mergedInstructions = mergeInstructions({ globalInstructions, repoInstructions: fetchedRepoInstructions, requestInstructions });
|
|
1539
1649
|
|
|
1540
1650
|
if (requestInstructions) {
|
|
1541
1651
|
await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
|
|
@@ -1554,6 +1664,29 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1554
1664
|
|
|
1555
1665
|
const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
|
|
1556
1666
|
|
|
1667
|
+
// Check if selected provider is an executable provider (external tool)
|
|
1668
|
+
const ProviderClass = getProviderClass(provider);
|
|
1669
|
+
if (ProviderClass?.isExecutable) {
|
|
1670
|
+
return handleExecutablePRAnalysis(req, res, {
|
|
1671
|
+
reviewId: review.id,
|
|
1672
|
+
review,
|
|
1673
|
+
prNumber,
|
|
1674
|
+
owner,
|
|
1675
|
+
repo,
|
|
1676
|
+
repository,
|
|
1677
|
+
worktreePath,
|
|
1678
|
+
prMetadata,
|
|
1679
|
+
selectedProvider: provider,
|
|
1680
|
+
selectedModel: model,
|
|
1681
|
+
repoInstructions,
|
|
1682
|
+
requestInstructions,
|
|
1683
|
+
combinedInstructions,
|
|
1684
|
+
runId,
|
|
1685
|
+
analysisId,
|
|
1686
|
+
reviewRepo
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1557
1690
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1558
1691
|
const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
|
|
1559
1692
|
const tier = requestTier ? resolveTier(requestTier) : 'balanced';
|
|
@@ -1563,6 +1696,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1563
1696
|
provider,
|
|
1564
1697
|
model,
|
|
1565
1698
|
tier,
|
|
1699
|
+
globalInstructions,
|
|
1566
1700
|
repoInstructions,
|
|
1567
1701
|
requestInstructions,
|
|
1568
1702
|
headSha: prMetadata.head_sha || null,
|
|
@@ -1596,7 +1730,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1596
1730
|
|
|
1597
1731
|
broadcastProgress(analysisId, initialStatus);
|
|
1598
1732
|
broadcastReviewEvent(review.id, { type: 'review:analysis_started', analysisId });
|
|
1599
|
-
const analysisConfig =
|
|
1733
|
+
const analysisConfig = appConfig;
|
|
1600
1734
|
const analysisPrContext = {
|
|
1601
1735
|
number: prNumber, owner, repo,
|
|
1602
1736
|
author: prMetadata.author, baseBranch: prMetadata.base_branch, headBranch: prMetadata.head_branch,
|
|
@@ -1626,7 +1760,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1626
1760
|
|
|
1627
1761
|
const progressCallback = createProgressCallback(analysisId);
|
|
1628
1762
|
|
|
1629
|
-
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
|
|
1763
|
+
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort })
|
|
1630
1764
|
.then(async result => {
|
|
1631
1765
|
logger.section('Analysis Results');
|
|
1632
1766
|
logger.success(`Analysis complete for PR #${prNumber}`);
|
|
@@ -1796,7 +1930,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
1796
1930
|
try {
|
|
1797
1931
|
const { owner, repo, number } = req.params;
|
|
1798
1932
|
const prNumber = parseInt(number);
|
|
1799
|
-
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
|
|
1933
|
+
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType, excludePrevious } = req.body || {};
|
|
1800
1934
|
|
|
1801
1935
|
if (isNaN(prNumber) || prNumber <= 0) {
|
|
1802
1936
|
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
@@ -1855,6 +1989,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
1855
1989
|
await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
|
|
1856
1990
|
}
|
|
1857
1991
|
|
|
1992
|
+
const prCouncilConfig = req.app.get('config') || {};
|
|
1858
1993
|
const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
|
|
1859
1994
|
db,
|
|
1860
1995
|
{
|
|
@@ -1866,7 +2001,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
1866
2001
|
headSha: prMetadata.head_sha,
|
|
1867
2002
|
logLabel: `PR #${prNumber}`,
|
|
1868
2003
|
initialStatusExtra: { prNumber, reviewType: 'pr' },
|
|
1869
|
-
config:
|
|
2004
|
+
config: prCouncilConfig,
|
|
2005
|
+
excludePrevious,
|
|
2006
|
+
serverPort: req.socket.localPort,
|
|
1870
2007
|
hookContext: {
|
|
1871
2008
|
mode: 'pr',
|
|
1872
2009
|
prContext: {
|
|
@@ -1884,7 +2021,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
1884
2021
|
},
|
|
1885
2022
|
councilConfig,
|
|
1886
2023
|
councilId,
|
|
1887
|
-
{ repoInstructions, requestInstructions },
|
|
2024
|
+
{ globalInstructions: prCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
|
|
1888
2025
|
configType
|
|
1889
2026
|
);
|
|
1890
2027
|
|
package/src/routes/reviews.js
CHANGED
|
@@ -456,6 +456,8 @@ router.get('/api/reviews/:reviewId/suggestions/check', validateReviewId, async (
|
|
|
456
456
|
* Query params:
|
|
457
457
|
* - levels: comma-separated list of levels (e.g., 'final,1,2'). Default: 'final'
|
|
458
458
|
* - runId: specific analysis run ID. Default: latest run
|
|
459
|
+
* - allRuns: when 'true', return suggestions from all analysis runs instead of only the latest
|
|
460
|
+
* - excludeRunId: when used with allRuns=true, exclude suggestions from specific run ID(s). Supports comma-separated values (e.g., 'id1,id2')
|
|
459
461
|
*/
|
|
460
462
|
router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, res) => {
|
|
461
463
|
try {
|
|
@@ -471,6 +473,13 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
471
473
|
// If not provided, defaults to the latest run
|
|
472
474
|
const runIdParam = req.query.runId;
|
|
473
475
|
|
|
476
|
+
// Parse allRuns flag — when true, skip the "latest run only" filter
|
|
477
|
+
const allRuns = req.query.allRuns === 'true';
|
|
478
|
+
|
|
479
|
+
// Parse optional excludeRunId — when used with allRuns=true, exclude suggestions from these runs
|
|
480
|
+
// Supports comma-separated values for excluding multiple run IDs (e.g., excludeRunId=id1,id2)
|
|
481
|
+
const excludeRunIds = req.query.excludeRunId ? req.query.excludeRunId.split(',').filter(Boolean) : [];
|
|
482
|
+
|
|
474
483
|
// Build level filter clause
|
|
475
484
|
const levelConditions = [];
|
|
476
485
|
requestedLevels.forEach(level => {
|
|
@@ -487,10 +496,22 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
487
496
|
: 'ai_level IS NULL';
|
|
488
497
|
|
|
489
498
|
// Build the run ID filter clause
|
|
490
|
-
//
|
|
499
|
+
// allRuns=true skips run filtering entirely — return suggestions from all runs
|
|
500
|
+
// runId param targets a specific run
|
|
501
|
+
// Default: subquery for the latest run only
|
|
491
502
|
let runIdFilter;
|
|
492
503
|
let queryParams;
|
|
493
|
-
if (
|
|
504
|
+
if (allRuns) {
|
|
505
|
+
if (excludeRunIds.length > 0) {
|
|
506
|
+
// Return suggestions from all runs except the excluded ones
|
|
507
|
+
runIdFilter = `ai_run_id NOT IN (${excludeRunIds.map(() => '?').join(', ')})`;
|
|
508
|
+
queryParams = [reviewId, ...excludeRunIds];
|
|
509
|
+
} else {
|
|
510
|
+
// No run ID filter — return suggestions from all analysis runs
|
|
511
|
+
runIdFilter = '1 = 1';
|
|
512
|
+
queryParams = [reviewId];
|
|
513
|
+
}
|
|
514
|
+
} else if (runIdParam) {
|
|
494
515
|
runIdFilter = 'ai_run_id = ?';
|
|
495
516
|
queryParams = [reviewId, runIdParam];
|
|
496
517
|
} else {
|
|
@@ -514,6 +535,8 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
514
535
|
queryParams = [reviewId, reviewId];
|
|
515
536
|
}
|
|
516
537
|
|
|
538
|
+
const statusFilter = "status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')";
|
|
539
|
+
|
|
517
540
|
const rows = await query(db, `
|
|
518
541
|
SELECT
|
|
519
542
|
id,
|
|
@@ -533,13 +556,14 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
533
556
|
reasoning,
|
|
534
557
|
status,
|
|
535
558
|
is_file_level,
|
|
559
|
+
severity,
|
|
536
560
|
created_at,
|
|
537
561
|
updated_at
|
|
538
562
|
FROM comments
|
|
539
563
|
WHERE review_id = ?
|
|
540
564
|
AND source = 'ai'
|
|
541
565
|
AND ${levelFilter}
|
|
542
|
-
AND
|
|
566
|
+
AND ${statusFilter}
|
|
543
567
|
AND (is_raw = 0 OR is_raw IS NULL)
|
|
544
568
|
AND ${runIdFilter}
|
|
545
569
|
ORDER BY
|
|
@@ -564,7 +588,8 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
564
588
|
body: row.body,
|
|
565
589
|
suggestionText: row.suggestion_text,
|
|
566
590
|
category: row.type,
|
|
567
|
-
title: row.title
|
|
591
|
+
title: row.title,
|
|
592
|
+
severity: row.severity
|
|
568
593
|
}, formatConfig);
|
|
569
594
|
|
|
570
595
|
return {
|
|
@@ -694,7 +719,8 @@ router.post('/api/reviews/:reviewId/suggestions/:id/adopt', validateReviewId, as
|
|
|
694
719
|
body: suggestion.body,
|
|
695
720
|
suggestionText: suggestion.suggestion_text,
|
|
696
721
|
category: suggestion.type,
|
|
697
|
-
title: suggestion.title
|
|
722
|
+
title: suggestion.title,
|
|
723
|
+
severity: suggestion.severity
|
|
698
724
|
}, formatConfig);
|
|
699
725
|
|
|
700
726
|
// Atomically adopt: create user comment and update suggestion status in one transaction
|
package/src/routes/shared.js
CHANGED
|
@@ -108,13 +108,50 @@ function determineCompletionInfo(result) {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Track which analysisIds have been announced on the index topic
|
|
112
|
+
const _indexAnnouncedIds = new Set();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Broadcast an analysis status change on the `index:analyses` topic
|
|
116
|
+
* so the index page can show/hide spinners in real time.
|
|
117
|
+
* @param {Object} data - Event data (type, analysisId, reviewId, etc.)
|
|
118
|
+
*/
|
|
119
|
+
function broadcastIndexAnalysisEvent(data) {
|
|
120
|
+
ws.broadcast('index:analyses', data);
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
/**
|
|
112
124
|
* Broadcast progress update to all WebSocket clients subscribed to `analysis:{analysisId}`.
|
|
125
|
+
* Also emits index-level start/end events for the index page spinners.
|
|
113
126
|
* @param {string} analysisId - Analysis ID
|
|
114
127
|
* @param {Object} progressData - Progress data to broadcast
|
|
115
128
|
*/
|
|
116
129
|
function broadcastProgress(analysisId, progressData) {
|
|
117
130
|
ws.broadcast('analysis:' + analysisId, { type: 'progress', ...progressData });
|
|
131
|
+
|
|
132
|
+
// Emit index-level events for analysis lifecycle transitions
|
|
133
|
+
const status = progressData.status;
|
|
134
|
+
if (status === 'running' && !_indexAnnouncedIds.has(analysisId)) {
|
|
135
|
+
_indexAnnouncedIds.add(analysisId);
|
|
136
|
+
broadcastIndexAnalysisEvent({
|
|
137
|
+
type: 'analysis_started',
|
|
138
|
+
analysisId,
|
|
139
|
+
reviewId: progressData.reviewId,
|
|
140
|
+
reviewType: progressData.reviewType || null,
|
|
141
|
+
repository: progressData.repository || null,
|
|
142
|
+
prNumber: progressData.prNumber || null
|
|
143
|
+
});
|
|
144
|
+
} else if (['completed', 'failed', 'cancelled'].includes(status) && _indexAnnouncedIds.has(analysisId)) {
|
|
145
|
+
_indexAnnouncedIds.delete(analysisId);
|
|
146
|
+
broadcastIndexAnalysisEvent({
|
|
147
|
+
type: 'analysis_ended',
|
|
148
|
+
analysisId,
|
|
149
|
+
reviewId: progressData.reviewId,
|
|
150
|
+
reviewType: progressData.reviewType || null,
|
|
151
|
+
repository: progressData.repository || null,
|
|
152
|
+
prNumber: progressData.prNumber || null
|
|
153
|
+
});
|
|
154
|
+
}
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
/**
|
|
@@ -231,11 +268,13 @@ function createProgressCallback(analysisId) {
|
|
|
231
268
|
}
|
|
232
269
|
}
|
|
233
270
|
|
|
234
|
-
// Per-voice orchestration
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
271
|
+
// Per-voice streams for orchestration and exec levels: store in voices map.
|
|
272
|
+
// For orchestration: prevents per-reviewer orchestration from overwriting
|
|
273
|
+
// the shared consolidation streamEvent/consolidationStep.
|
|
274
|
+
// For exec: ensures exec.voices[voiceId] stays updated with stream events
|
|
275
|
+
// so the frontend can render both state and stream text from the same object.
|
|
276
|
+
const isPerVoiceStream = ((level === 'orchestration' || consolidationMatch) || level === 'exec') && progressUpdate.voiceId;
|
|
277
|
+
if (isPerVoiceStream) {
|
|
239
278
|
if (!currentStatus.levels[levelKey].voices) {
|
|
240
279
|
currentStatus.levels[levelKey].voices = {};
|
|
241
280
|
}
|
|
@@ -301,6 +340,32 @@ function createProgressCallback(analysisId) {
|
|
|
301
340
|
}
|
|
302
341
|
}
|
|
303
342
|
|
|
343
|
+
// Handle executable voice progress (level === 'exec').
|
|
344
|
+
// Executable voices run a single analysis step instead of L1/L2/L3.
|
|
345
|
+
// Track per-voice state in levels.exec.voices, mirroring the L1-L3 pattern.
|
|
346
|
+
if (level === 'exec' && currentStatus.levels.exec) {
|
|
347
|
+
if (progressUpdate.voiceId) {
|
|
348
|
+
if (!currentStatus.levels.exec.voices) {
|
|
349
|
+
currentStatus.levels.exec.voices = {};
|
|
350
|
+
}
|
|
351
|
+
currentStatus.levels.exec.voices[progressUpdate.voiceId] = {
|
|
352
|
+
status: progressUpdate.status || 'running',
|
|
353
|
+
progress: progressUpdate.progress || 'In progress...'
|
|
354
|
+
};
|
|
355
|
+
currentStatus.levels.exec.voiceId = progressUpdate.voiceId;
|
|
356
|
+
currentStatus.levels.exec.status = progressUpdate.status || 'running';
|
|
357
|
+
currentStatus.levels.exec.progress = progressUpdate.progress || 'In progress...';
|
|
358
|
+
currentStatus.levels.exec.streamEvent = undefined;
|
|
359
|
+
} else {
|
|
360
|
+
currentStatus.levels.exec = {
|
|
361
|
+
status: progressUpdate.status || 'running',
|
|
362
|
+
progress: progressUpdate.progress || 'In progress...',
|
|
363
|
+
streamEvent: undefined,
|
|
364
|
+
voiceId: undefined
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
304
369
|
// Handle orchestration and consolidation as level 4.
|
|
305
370
|
//
|
|
306
371
|
// levels[4] is intentionally denormalized with two parallel maps:
|
|
@@ -411,6 +476,8 @@ module.exports = {
|
|
|
411
476
|
getModel,
|
|
412
477
|
determineCompletionInfo,
|
|
413
478
|
broadcastProgress,
|
|
479
|
+
broadcastIndexAnalysisEvent,
|
|
480
|
+
_indexAnnouncedIds,
|
|
414
481
|
broadcastSetupProgress,
|
|
415
482
|
registerProcess,
|
|
416
483
|
killProcesses,
|
package/src/routes/worktrees.js
CHANGED
|
@@ -154,7 +154,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
154
154
|
json_extract(pm.pr_data, '$.html_url') as html_url,
|
|
155
155
|
w.id as worktree_id,
|
|
156
156
|
w.path as worktree_path,
|
|
157
|
-
w.branch
|
|
157
|
+
w.branch,
|
|
158
|
+
(SELECT r.id FROM reviews r WHERE r.pr_number = pm.pr_number AND r.repository = pm.repository COLLATE NOCASE ORDER BY r.updated_at DESC LIMIT 1) as review_id
|
|
158
159
|
FROM pr_metadata pm
|
|
159
160
|
LEFT JOIN worktrees w ON pm.pr_number = w.pr_number AND pm.repository = w.repository COLLATE NOCASE
|
|
160
161
|
WHERE pm.title IS NOT NULL AND pm.title != ''
|
|
@@ -195,7 +196,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
195
196
|
last_accessed_at: row.last_accessed_at,
|
|
196
197
|
created_at: row.created_at,
|
|
197
198
|
storage_status: storageStatus,
|
|
198
|
-
html_url: row.html_url || null
|
|
199
|
+
html_url: row.html_url || null,
|
|
200
|
+
review_id: row.review_id || null
|
|
199
201
|
});
|
|
200
202
|
}
|
|
201
203
|
|
|
@@ -8,15 +8,17 @@ const { getEmoji } = require('./category-emoji');
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Preset format templates for adopted comments.
|
|
11
|
-
* Template placeholders: {emoji}, {category}, {title}, {description}, {suggestion}
|
|
11
|
+
* Template placeholders: {emoji}, {category}, {severity}, {SEVERITY}, {title}, {description}, {suggestion}
|
|
12
|
+
* {severity} renders as Title Case (e.g., "Critical"), {SEVERITY} renders as UPPERCASE (e.g., "CRITICAL").
|
|
12
13
|
* Conditional sections: {?field}...{/field} — content is kept when field is truthy, stripped when falsy.
|
|
14
|
+
* Nesting is supported: {?outer}...{?inner}...{/inner}...{/outer}
|
|
13
15
|
*/
|
|
14
16
|
const PRESETS = {
|
|
15
17
|
legacy: '{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}',
|
|
16
18
|
minimal: '[{category}] {description}{?suggestion}\n\n{suggestion}{/suggestion}',
|
|
17
19
|
plain: '{description}{?suggestion}\n\n{suggestion}{/suggestion}',
|
|
18
20
|
'emoji-only': '{emoji} {description}{?suggestion}\n\n{suggestion}{/suggestion}',
|
|
19
|
-
maximal: '{emoji} **{category}**{?title}: {title}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}'
|
|
21
|
+
maximal: '{emoji} **{category}**{?title}: {title}{?severity} ({severity}){/severity}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}'
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -63,25 +65,32 @@ function capitalizeCategory(category) {
|
|
|
63
65
|
* @returns {string} Template with conditional sections resolved
|
|
64
66
|
*/
|
|
65
67
|
function processConditionalSections(template, values) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
// Loop to resolve nested conditionals (outer pass exposes inner ones)
|
|
69
|
+
let result = template;
|
|
70
|
+
let prev;
|
|
71
|
+
do {
|
|
72
|
+
prev = result;
|
|
73
|
+
result = result.replace(/\{\?(\w+)\}([\s\S]*?)\{\/\1\}/g, (match, fieldName, content) => {
|
|
74
|
+
const value = values[fieldName];
|
|
75
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
76
|
+
return content;
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
});
|
|
80
|
+
} while (result !== prev);
|
|
81
|
+
return result;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
|
76
85
|
* Format an adopted comment using the given format configuration.
|
|
77
86
|
* Handles legacy data where suggestion_text was concatenated into body.
|
|
78
87
|
*
|
|
79
|
-
* @param {{ body: string, suggestionText?: string, category?: string, title?: string }} fields
|
|
88
|
+
* @param {{ body: string, suggestionText?: string, category?: string, title?: string, severity?: string }} fields
|
|
80
89
|
* @param {{ template: string, emojiOverrides: Object, categoryOverrides: Object }} formatConfig
|
|
81
90
|
* @returns {string} Formatted comment text
|
|
82
91
|
*/
|
|
83
92
|
function formatAdoptedComment(fields, formatConfig) {
|
|
84
|
-
const { body, title } = fields;
|
|
93
|
+
const { body, title, severity } = fields;
|
|
85
94
|
let { category, suggestionText } = fields;
|
|
86
95
|
|
|
87
96
|
if (!category) {
|
|
@@ -111,9 +120,15 @@ function formatAdoptedComment(fields, formatConfig) {
|
|
|
111
120
|
const capitalizedCategory = capitalizeCategory(category);
|
|
112
121
|
|
|
113
122
|
// Process conditional sections first, then replace individual placeholders
|
|
123
|
+
const capitalizedSeverity = severity ? capitalizeCategory(severity) : '';
|
|
124
|
+
|
|
125
|
+
const uppercaseSeverity = severity ? severity.toUpperCase() : '';
|
|
126
|
+
|
|
114
127
|
const fieldValues = {
|
|
115
128
|
suggestion: suggestionText || '',
|
|
116
129
|
title: title || '',
|
|
130
|
+
severity: capitalizedSeverity,
|
|
131
|
+
SEVERITY: uppercaseSeverity,
|
|
117
132
|
emoji,
|
|
118
133
|
category: capitalizedCategory,
|
|
119
134
|
description
|
|
@@ -124,6 +139,8 @@ function formatAdoptedComment(fields, formatConfig) {
|
|
|
124
139
|
// Replace placeholders
|
|
125
140
|
result = result.replace(/\{emoji\}/g, emoji);
|
|
126
141
|
result = result.replace(/\{category\}/g, capitalizedCategory);
|
|
142
|
+
result = result.replace(/\{severity\}/g, capitalizedSeverity);
|
|
143
|
+
result = result.replace(/\{SEVERITY\}/g, uppercaseSeverity);
|
|
127
144
|
result = result.replace(/\{title\}/g, title || '');
|
|
128
145
|
result = result.replace(/\{description\}/g, description);
|
|
129
146
|
result = result.replace(/\{suggestion\}/g, suggestionText || '');
|