@in-the-loop-labs/pair-review 2.3.3 → 2.4.1
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/.pi/skills/review-model-guidance/SKILL.md +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/README.md +15 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +287 -14
- package/public/index.html +121 -57
- package/public/js/components/AIPanel.js +2 -1
- package/public/js/components/AdvancedConfigTab.js +2 -2
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +187 -28
- package/public/js/components/CouncilProgressModal.js +4 -7
- package/public/js/components/SplitButton.js +66 -1
- package/public/js/components/VoiceCentricConfigTab.js +2 -2
- package/public/js/index.js +274 -21
- package/public/js/pr.js +194 -5
- package/public/local.html +8 -1
- package/public/pr.html +17 -2
- package/src/ai/codex-provider.js +14 -2
- package/src/ai/copilot-provider.js +1 -10
- package/src/ai/cursor-agent-provider.js +1 -10
- package/src/ai/gemini-provider.js +8 -17
- package/src/chat/acp-bridge.js +456 -0
- package/src/chat/api-reference.js +539 -0
- package/src/chat/chat-providers.js +290 -0
- package/src/chat/claude-code-bridge.js +499 -0
- package/src/chat/codex-bridge.js +601 -0
- package/src/chat/pi-bridge.js +56 -3
- package/src/chat/prompt-builder.js +12 -11
- package/src/chat/session-manager.js +110 -29
- package/src/config.js +4 -2
- package/src/database.js +50 -2
- package/src/github/client.js +43 -0
- package/src/routes/chat.js +60 -27
- package/src/routes/config.js +24 -1
- package/src/routes/github-collections.js +126 -0
- package/src/routes/mcp.js +2 -1
- package/src/routes/pr.js +166 -2
- package/src/routes/reviews.js +2 -1
- package/src/routes/shared.js +70 -49
- package/src/server.js +27 -1
- package/src/utils/safe-parse-json.js +19 -0
- package/.pi/skills/pair-review-api/SKILL.md +0 -448
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Collections Routes
|
|
4
|
+
*
|
|
5
|
+
* Handles endpoints for PR collections:
|
|
6
|
+
* - Review Requests: PRs where the user's review is requested
|
|
7
|
+
* - My PRs: PRs authored by the user
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const express = require('express');
|
|
11
|
+
const { query, run, withTransaction } = require('../database');
|
|
12
|
+
const { GitHubClient } = require('../github/client');
|
|
13
|
+
const { getGitHubToken } = require('../config');
|
|
14
|
+
const logger = require('../utils/logger');
|
|
15
|
+
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get cached review request PRs.
|
|
20
|
+
*/
|
|
21
|
+
router.get('/api/github/review-requests', async (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
const db = req.app.get('db');
|
|
24
|
+
const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
|
|
25
|
+
|
|
26
|
+
const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
|
|
27
|
+
res.json({ success: true, prs: rows, fetched_at: fetchedAt });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
logger.error('Failed to fetch review requests:', error);
|
|
30
|
+
res.status(500).json({ success: false, error: 'Failed to fetch review requests' });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Refresh review request PRs from GitHub.
|
|
36
|
+
*/
|
|
37
|
+
router.post('/api/github/review-requests/refresh', async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const config = req.app.get('config');
|
|
40
|
+
const githubToken = getGitHubToken(config);
|
|
41
|
+
if (!githubToken) {
|
|
42
|
+
return res.status(401).json({ success: false, error: 'GitHub token not configured' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const db = req.app.get('db');
|
|
46
|
+
const client = new GitHubClient(githubToken);
|
|
47
|
+
const user = await client.getAuthenticatedUser();
|
|
48
|
+
const prs = await client.searchPullRequests(`is:pr is:open user-review-requested:${user.login}`);
|
|
49
|
+
|
|
50
|
+
await withTransaction(db, async () => {
|
|
51
|
+
await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['review-requests']);
|
|
52
|
+
for (const pr of prs) {
|
|
53
|
+
await run(db,
|
|
54
|
+
'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
55
|
+
[pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'review-requests']
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
|
|
61
|
+
const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
|
|
62
|
+
res.json({ success: true, prs: rows, fetched_at: fetchedAt });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.status === 401 || error.status === 403) {
|
|
65
|
+
return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
|
|
66
|
+
}
|
|
67
|
+
logger.error('Failed to refresh review requests:', error);
|
|
68
|
+
res.status(500).json({ success: false, error: 'Failed to refresh review requests' });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get cached user's own PRs.
|
|
74
|
+
*/
|
|
75
|
+
router.get('/api/github/my-prs', async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const db = req.app.get('db');
|
|
78
|
+
const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
|
|
79
|
+
|
|
80
|
+
const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
|
|
81
|
+
res.json({ success: true, prs: rows, fetched_at: fetchedAt });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error('Failed to fetch my PRs:', error);
|
|
84
|
+
res.status(500).json({ success: false, error: 'Failed to fetch my PRs' });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Refresh user's own PRs from GitHub.
|
|
90
|
+
*/
|
|
91
|
+
router.post('/api/github/my-prs/refresh', async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const config = req.app.get('config');
|
|
94
|
+
const githubToken = getGitHubToken(config);
|
|
95
|
+
if (!githubToken) {
|
|
96
|
+
return res.status(401).json({ success: false, error: 'GitHub token not configured' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const db = req.app.get('db');
|
|
100
|
+
const client = new GitHubClient(githubToken);
|
|
101
|
+
const user = await client.getAuthenticatedUser();
|
|
102
|
+
const prs = await client.searchPullRequests(`is:pr is:open author:${user.login}`);
|
|
103
|
+
|
|
104
|
+
await withTransaction(db, async () => {
|
|
105
|
+
await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['my-prs']);
|
|
106
|
+
for (const pr of prs) {
|
|
107
|
+
await run(db,
|
|
108
|
+
'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
109
|
+
[pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'my-prs']
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
|
|
115
|
+
const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
|
|
116
|
+
res.json({ success: true, prs: rows, fetched_at: fetchedAt });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.status === 401 || error.status === 403) {
|
|
119
|
+
return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
|
|
120
|
+
}
|
|
121
|
+
logger.error('Failed to refresh my PRs:', error);
|
|
122
|
+
res.status(500).json({ success: false, error: 'Failed to refresh my PRs' });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
module.exports = router;
|
package/src/routes/mcp.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
broadcastProgress,
|
|
22
22
|
createProgressCallback
|
|
23
23
|
} = require('./shared');
|
|
24
|
+
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
24
25
|
|
|
25
26
|
// All valid tier values: canonical tiers + aliases (for Zod enum validation)
|
|
26
27
|
const ALL_TIER_VALUES = /** @type {[string, ...string[]]} */ ([...TIERS, ...Object.keys(TIER_ALIASES)]);
|
|
@@ -428,7 +429,7 @@ function createMCPServer(db, options = {}) {
|
|
|
428
429
|
type: s.type,
|
|
429
430
|
ai_confidence: s.ai_confidence,
|
|
430
431
|
status: s.status,
|
|
431
|
-
reasoning:
|
|
432
|
+
reasoning: safeParseJson(s.reasoning),
|
|
432
433
|
}))
|
|
433
434
|
}, null, 2)
|
|
434
435
|
}]
|
package/src/routes/pr.js
CHANGED
|
@@ -24,6 +24,7 @@ const Analyzer = require('../ai/analyzer');
|
|
|
24
24
|
const { v4: uuidv4 } = require('uuid');
|
|
25
25
|
const fs = require('fs').promises;
|
|
26
26
|
const path = require('path');
|
|
27
|
+
const { getGitHubToken } = require('../config');
|
|
27
28
|
const logger = require('../utils/logger');
|
|
28
29
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
29
30
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
@@ -37,6 +38,7 @@ const {
|
|
|
37
38
|
createProgressCallback,
|
|
38
39
|
parseEnabledLevels
|
|
39
40
|
} = require('./shared');
|
|
41
|
+
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
40
42
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
41
43
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
42
44
|
const analysesRouter = require('./analyses');
|
|
@@ -199,7 +201,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
199
201
|
let pendingDraft = null;
|
|
200
202
|
if (review) {
|
|
201
203
|
const config = req.app.get('config');
|
|
202
|
-
const githubToken = config
|
|
204
|
+
const githubToken = getGitHubToken(config || {}) || req.app.get('githubToken');
|
|
203
205
|
|
|
204
206
|
if (githubToken) {
|
|
205
207
|
try {
|
|
@@ -527,7 +529,7 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
|
|
|
527
529
|
}
|
|
528
530
|
|
|
529
531
|
// Initialize GitHub client and check for pending drafts on GitHub
|
|
530
|
-
const githubToken = config
|
|
532
|
+
const githubToken = getGitHubToken(config) || req.app.get('githubToken');
|
|
531
533
|
if (!githubToken) {
|
|
532
534
|
return res.status(500).json({
|
|
533
535
|
error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
|
|
@@ -1803,4 +1805,166 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
1803
1805
|
}
|
|
1804
1806
|
});
|
|
1805
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* Get shareable review data for a PR
|
|
1810
|
+
* Returns PR metadata, diff, and AI analysis results in a single payload
|
|
1811
|
+
* for consumption by external share sites.
|
|
1812
|
+
*
|
|
1813
|
+
* NOTE: Intentionally PR-only. Sharing requires a stable PR reference
|
|
1814
|
+
* (owner/repo/number) that external consumers can resolve. Local mode
|
|
1815
|
+
* reviews operate on uncommitted changes with no such reference.
|
|
1816
|
+
*/
|
|
1817
|
+
router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
|
|
1818
|
+
try {
|
|
1819
|
+
const { owner, repo, number } = req.params;
|
|
1820
|
+
const prNumber = parseInt(number);
|
|
1821
|
+
|
|
1822
|
+
if (isNaN(prNumber) || prNumber <= 0) {
|
|
1823
|
+
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const repository = normalizeRepository(owner, repo);
|
|
1827
|
+
const db = req.app.get('db');
|
|
1828
|
+
|
|
1829
|
+
// Get PR metadata
|
|
1830
|
+
const prMetadata = await queryOne(db, `
|
|
1831
|
+
SELECT id, pr_number, repository, title, author, base_branch, head_branch, pr_data
|
|
1832
|
+
FROM pr_metadata
|
|
1833
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
1834
|
+
`, [prNumber, repository]);
|
|
1835
|
+
|
|
1836
|
+
if (!prMetadata) {
|
|
1837
|
+
return res.status(404).json({
|
|
1838
|
+
error: `Pull request #${prNumber} not found in repository ${repository}`
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Parse PR data for diff and SHAs
|
|
1843
|
+
let prData = {};
|
|
1844
|
+
try {
|
|
1845
|
+
prData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
|
|
1846
|
+
} catch (parseError) {
|
|
1847
|
+
logger.warn('Error parsing PR data JSON for share:', parseError.message);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Get review record
|
|
1851
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1852
|
+
const review = await reviewRepo.getReviewByPR(prNumber, repository);
|
|
1853
|
+
|
|
1854
|
+
// Build changed files list
|
|
1855
|
+
// changed_files may use 'insertions' (from git diff) or 'additions' (from GitHub API)
|
|
1856
|
+
const changedFiles = (prData.changed_files || []).map(f => ({
|
|
1857
|
+
path: f.file,
|
|
1858
|
+
additions: f.insertions ?? f.additions ?? 0,
|
|
1859
|
+
deletions: f.deletions ?? 0
|
|
1860
|
+
}));
|
|
1861
|
+
|
|
1862
|
+
// Get the authenticated user (who is sharing)
|
|
1863
|
+
let sharedBy = null;
|
|
1864
|
+
try {
|
|
1865
|
+
const config = req.app.get('config') || {};
|
|
1866
|
+
const githubToken = getGitHubToken(config);
|
|
1867
|
+
if (githubToken) {
|
|
1868
|
+
const githubClient = new GitHubClient(githubToken);
|
|
1869
|
+
const user = await githubClient.getAuthenticatedUser();
|
|
1870
|
+
sharedBy = user.login;
|
|
1871
|
+
}
|
|
1872
|
+
} catch (authError) {
|
|
1873
|
+
logger.warn('Could not get authenticated user for share:', authError.message);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Build response payload
|
|
1877
|
+
const payload = {
|
|
1878
|
+
owner,
|
|
1879
|
+
repo,
|
|
1880
|
+
prNumber,
|
|
1881
|
+
title: prMetadata.title || '',
|
|
1882
|
+
author: prMetadata.author || '',
|
|
1883
|
+
baseBranch: prMetadata.base_branch || '',
|
|
1884
|
+
headBranch: prMetadata.head_branch || '',
|
|
1885
|
+
baseSha: prData.base_sha || '',
|
|
1886
|
+
headSha: prData.head_sha || '',
|
|
1887
|
+
diff: prData.diff || '',
|
|
1888
|
+
changedFiles,
|
|
1889
|
+
sharedBy,
|
|
1890
|
+
run: null,
|
|
1891
|
+
suggestions: []
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
// If we have a review, get the analysis run and its suggestions
|
|
1895
|
+
// Supports optional runId query param for specific run selection
|
|
1896
|
+
if (review) {
|
|
1897
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1898
|
+
const requestedRunId = req.query.runId;
|
|
1899
|
+
let targetRun = null;
|
|
1900
|
+
|
|
1901
|
+
if (requestedRunId) {
|
|
1902
|
+
// Specific run requested - fetch it directly
|
|
1903
|
+
targetRun = await analysisRunRepo.getById(requestedRunId);
|
|
1904
|
+
// Verify it belongs to this review and is completed
|
|
1905
|
+
if (!targetRun || targetRun.review_id !== review.id || targetRun.status !== 'completed') {
|
|
1906
|
+
targetRun = null;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// If no specific run requested or it wasn't found/valid, fall back to the most recently completed run
|
|
1911
|
+
if (!targetRun) {
|
|
1912
|
+
const runs = await analysisRunRepo.getByReviewId(review.id);
|
|
1913
|
+
targetRun = runs.find(r => r.status === 'completed') || null;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (targetRun) {
|
|
1917
|
+
payload.run = {
|
|
1918
|
+
id: targetRun.id,
|
|
1919
|
+
provider: targetRun.provider || null,
|
|
1920
|
+
model: targetRun.model || null,
|
|
1921
|
+
tier: targetRun.tier || null,
|
|
1922
|
+
summary: targetRun.summary || null,
|
|
1923
|
+
completedAt: targetRun.completed_at || null,
|
|
1924
|
+
duration: targetRun.started_at && targetRun.completed_at
|
|
1925
|
+
? new Date(targetRun.completed_at).getTime() - new Date(targetRun.started_at).getTime()
|
|
1926
|
+
: null,
|
|
1927
|
+
customInstructions: targetRun.custom_instructions || targetRun.request_instructions || null
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
// Get suggestions for this run
|
|
1931
|
+
const rows = await query(db, `
|
|
1932
|
+
SELECT
|
|
1933
|
+
id, file, line_start, line_end, side, type, title, body,
|
|
1934
|
+
suggestion_text, ai_confidence, reasoning, status, is_file_level
|
|
1935
|
+
FROM comments
|
|
1936
|
+
WHERE review_id = ?
|
|
1937
|
+
AND source = 'ai'
|
|
1938
|
+
AND ai_run_id = ?
|
|
1939
|
+
AND ai_level IS NULL
|
|
1940
|
+
AND (is_raw = 0 OR is_raw IS NULL)
|
|
1941
|
+
AND status IN ('active', 'adopted')
|
|
1942
|
+
ORDER BY file, line_start
|
|
1943
|
+
`, [review.id, targetRun.id]);
|
|
1944
|
+
|
|
1945
|
+
payload.suggestions = rows.map(row => ({
|
|
1946
|
+
id: row.id,
|
|
1947
|
+
file: row.file,
|
|
1948
|
+
lineStart: row.line_start,
|
|
1949
|
+
lineEnd: row.line_end,
|
|
1950
|
+
side: row.side || 'RIGHT',
|
|
1951
|
+
type: row.type || 'comment',
|
|
1952
|
+
title: row.title || '',
|
|
1953
|
+
body: row.body || '',
|
|
1954
|
+
suggestionText: row.suggestion_text || '',
|
|
1955
|
+
confidence: row.ai_confidence != null ? row.ai_confidence : null,
|
|
1956
|
+
reasoning: safeParseJson(row.reasoning, []),
|
|
1957
|
+
status: row.status,
|
|
1958
|
+
isFileLevel: row.is_file_level === 1
|
|
1959
|
+
}));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
res.json(payload);
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
logger.error('Error generating share data:', error);
|
|
1966
|
+
res.status(500).json({ error: 'Failed to generate share data' });
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1806
1970
|
module.exports = router;
|
package/src/routes/reviews.js
CHANGED
|
@@ -20,6 +20,7 @@ const simpleGit = require('simple-git');
|
|
|
20
20
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
21
21
|
const { normalizeRepository } = require('../utils/paths');
|
|
22
22
|
const { resolveFormat, formatAdoptedComment: formatComment } = require('../utils/comment-formatter');
|
|
23
|
+
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
23
24
|
|
|
24
25
|
const router = express.Router();
|
|
25
26
|
|
|
@@ -568,7 +569,7 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
|
|
|
568
569
|
|
|
569
570
|
return {
|
|
570
571
|
...row,
|
|
571
|
-
reasoning:
|
|
572
|
+
reasoning: safeParseJson(row.reasoning),
|
|
572
573
|
formattedBody
|
|
573
574
|
};
|
|
574
575
|
});
|
package/src/routes/shared.js
CHANGED
|
@@ -231,16 +231,34 @@ function createProgressCallback(analysisId) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
234
|
+
// Per-voice orchestration streams: store in voices map, not shared state.
|
|
235
|
+
// This prevents per-reviewer orchestration (within each voice's analysis)
|
|
236
|
+
// from overwriting the shared consolidation streamEvent/consolidationStep.
|
|
237
|
+
const isPerVoiceOrchestration = (level === 'orchestration' || consolidationMatch) && progressUpdate.voiceId;
|
|
238
|
+
if (isPerVoiceOrchestration) {
|
|
239
|
+
if (!currentStatus.levels[levelKey].voices) {
|
|
240
|
+
currentStatus.levels[levelKey].voices = {};
|
|
241
|
+
}
|
|
242
|
+
if (!currentStatus.levels[levelKey].voices[progressUpdate.voiceId]) {
|
|
243
|
+
currentStatus.levels[levelKey].voices[progressUpdate.voiceId] = { status: 'running' };
|
|
244
|
+
}
|
|
245
|
+
currentStatus.levels[levelKey].voices[progressUpdate.voiceId].streamEvent = evt;
|
|
246
|
+
// Levels 1-3: stream events are stored in the shared levels[n].streamEvent field
|
|
247
|
+
// with voiceId as a routing discriminator (only one voice streams per level at a time).
|
|
248
|
+
// Level 4 is different (handled above) because it has both per-voice orchestration
|
|
249
|
+
// AND shared cross-voice consolidation, requiring separate storage paths.
|
|
250
|
+
} else {
|
|
251
|
+
currentStatus.levels[levelKey].streamEvent = evt;
|
|
252
|
+
// Propagate voiceId so council progress modal can identify active voice
|
|
253
|
+
if (progressUpdate.voiceId) {
|
|
254
|
+
currentStatus.levels[levelKey].voiceId = progressUpdate.voiceId;
|
|
255
|
+
}
|
|
256
|
+
// Propagate consolidation step so frontend can identify active consolidation child
|
|
257
|
+
if (consolidationMatch) {
|
|
258
|
+
currentStatus.levels[levelKey].consolidationStep = `L${consolidationMatch[1]}`;
|
|
259
|
+
} else if (level === 'orchestration') {
|
|
260
|
+
currentStatus.levels[levelKey].consolidationStep = 'orchestration';
|
|
261
|
+
}
|
|
244
262
|
}
|
|
245
263
|
activeAnalyses.set(analysisId, currentStatus);
|
|
246
264
|
|
|
@@ -295,48 +313,51 @@ function createProgressCallback(analysisId) {
|
|
|
295
313
|
// Both maps must be preserved across updates since each progress event only
|
|
296
314
|
// reports on a single step or voice at a time.
|
|
297
315
|
if (level === 'orchestration' || consolidationMatch) {
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
status: progressUpdate.status || 'running',
|
|
304
|
-
progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...')
|
|
305
|
-
};
|
|
306
|
-
// Derive the top-level consolidation status from the aggregate of step statuses
|
|
307
|
-
// so that a single step completing doesn't mark the whole phase as completed
|
|
308
|
-
const stepStatuses = Object.values(steps).map(s => s.status);
|
|
309
|
-
const derivedStatus = stepStatuses.every(s => s === 'completed') ? 'completed'
|
|
310
|
-
: stepStatuses.some(s => s === 'failed') ? 'failed'
|
|
311
|
-
: stepStatuses.some(s => s === 'running') ? 'running'
|
|
312
|
-
: progressUpdate.status || 'running';
|
|
313
|
-
// Preserve existing per-voice orchestration states when rebuilding level 4
|
|
314
|
-
const existingVoices = existing.voices ? { ...existing.voices } : undefined;
|
|
315
|
-
currentStatus.levels[4] = {
|
|
316
|
-
status: derivedStatus,
|
|
317
|
-
progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...'),
|
|
318
|
-
streamEvent: existing.streamEvent,
|
|
319
|
-
consolidationStep: step,
|
|
320
|
-
steps,
|
|
321
|
-
voices: existingVoices
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
// Track per-voice orchestration state (voice-centric council mode):
|
|
325
|
-
// When a voiceId is present, store per-voice status in levels[4].voices
|
|
326
|
-
// so the frontend can update individual reviewer's consolidation row.
|
|
316
|
+
// Per-voice orchestration updates (voiceId present): only update the per-voice
|
|
317
|
+
// entry in levels[4].voices. Do NOT touch the shared consolidation state (steps,
|
|
318
|
+
// consolidationStep, streamEvent, top-level progress). This prevents per-reviewer
|
|
319
|
+
// orchestration (within each voice's analysis) from being confused with the
|
|
320
|
+
// overall cross-voice or cross-level consolidation.
|
|
327
321
|
if (progressUpdate.voiceId) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
322
|
+
const existing = currentStatus.levels[4] || {};
|
|
323
|
+
const existingVoices = existing.voices ? { ...existing.voices } : {};
|
|
324
|
+
const prev = existingVoices[progressUpdate.voiceId] || {};
|
|
325
|
+
const voiceStatus = progressUpdate.status || 'running';
|
|
326
|
+
existingVoices[progressUpdate.voiceId] = voiceStatus === 'running'
|
|
327
|
+
? { ...prev, status: voiceStatus, progress: progressUpdate.progress || 'Consolidating...' }
|
|
328
|
+
: { status: voiceStatus, progress: progressUpdate.progress || 'Consolidating...' };
|
|
329
|
+
currentStatus.levels[4] = {
|
|
330
|
+
...existing,
|
|
331
|
+
voices: existingVoices,
|
|
332
|
+
voiceId: progressUpdate.voiceId
|
|
333
|
+
};
|
|
334
|
+
} else {
|
|
335
|
+
// Shared consolidation update (no voiceId): update steps map and derive
|
|
336
|
+
// aggregate status. This is the cross-level or cross-voice consolidation.
|
|
337
|
+
const step = consolidationMatch ? `L${consolidationMatch[1]}` : 'orchestration';
|
|
338
|
+
const existing = currentStatus.levels[4] || {};
|
|
339
|
+
const steps = { ...(existing.steps || {}) };
|
|
340
|
+
steps[step] = {
|
|
332
341
|
status: progressUpdate.status || 'running',
|
|
333
|
-
progress: progressUpdate.progress || 'Consolidating...'
|
|
342
|
+
progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...')
|
|
343
|
+
};
|
|
344
|
+
// Derive the top-level consolidation status from the aggregate of step statuses
|
|
345
|
+
// so that a single step completing doesn't mark the whole phase as completed
|
|
346
|
+
const stepStatuses = Object.values(steps).map(s => s.status);
|
|
347
|
+
const derivedStatus = stepStatuses.every(s => s === 'completed') ? 'completed'
|
|
348
|
+
: stepStatuses.some(s => s === 'failed') ? 'failed'
|
|
349
|
+
: stepStatuses.some(s => s === 'running') ? 'running'
|
|
350
|
+
: progressUpdate.status || 'running';
|
|
351
|
+
// Preserve existing per-voice orchestration states when rebuilding level 4
|
|
352
|
+
const existingVoices = existing.voices ? { ...existing.voices } : undefined;
|
|
353
|
+
currentStatus.levels[4] = {
|
|
354
|
+
status: derivedStatus,
|
|
355
|
+
progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...'),
|
|
356
|
+
streamEvent: existing.streamEvent,
|
|
357
|
+
consolidationStep: step,
|
|
358
|
+
steps,
|
|
359
|
+
voices: existingVoices
|
|
334
360
|
};
|
|
335
|
-
// Last-writer-wins: reflects whichever voice reported most recently.
|
|
336
|
-
// Intentional — mirrors levels 1-3 behavior (line ~334) and the frontend
|
|
337
|
-
// uses per-voice detail from the `voices` map, not this top-level field.
|
|
338
|
-
// This field exists for backward compat with single-model progress routing.
|
|
339
|
-
currentStatus.levels[4].voiceId = progressUpdate.voiceId;
|
|
340
361
|
}
|
|
341
362
|
}
|
|
342
363
|
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ const { loadConfig, getGitHubToken, resolveDbName, warnIfDevModeWithoutDbName }
|
|
|
5
5
|
const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./database');
|
|
6
6
|
const { normalizeRepository } = require('./utils/paths');
|
|
7
7
|
const { applyConfigOverrides, checkAllProviders } = require('./ai');
|
|
8
|
+
const { checkAllChatProviders } = require('./chat/chat-providers');
|
|
8
9
|
const logger = require('./utils/logger');
|
|
9
10
|
const { attachWebSocket, closeAll: closeAllWS } = require('./ws');
|
|
10
11
|
|
|
@@ -161,6 +162,25 @@ async function startServer(sharedDb = null) {
|
|
|
161
162
|
// Middleware
|
|
162
163
|
app.use(requestLogger);
|
|
163
164
|
app.use(express.json());
|
|
165
|
+
|
|
166
|
+
// CORS middleware for share endpoints
|
|
167
|
+
// Allows configured external origins to fetch share data
|
|
168
|
+
const shareAllowedOrigins = config.share?.allowed_origins || [];
|
|
169
|
+
if (shareAllowedOrigins.length > 0) {
|
|
170
|
+
app.use('/api/pr/:owner/:repo/:number/share', (req, res, next) => {
|
|
171
|
+
const origin = req.headers.origin;
|
|
172
|
+
if (origin && shareAllowedOrigins.includes(origin)) {
|
|
173
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
174
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
175
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
176
|
+
res.setHeader('Vary', 'Origin');
|
|
177
|
+
}
|
|
178
|
+
if (req.method === 'OPTIONS') {
|
|
179
|
+
return res.sendStatus(204);
|
|
180
|
+
}
|
|
181
|
+
next();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
164
184
|
|
|
165
185
|
// Static files with cache control headers
|
|
166
186
|
// In dev_mode, all caching is disabled to avoid stale resources during development
|
|
@@ -261,10 +281,11 @@ async function startServer(sharedDb = null) {
|
|
|
261
281
|
const councilRoutes = require('./routes/councils');
|
|
262
282
|
const chatRoutes = require('./routes/chat');
|
|
263
283
|
const contextFilesRoutes = require('./routes/context-files');
|
|
284
|
+
const githubCollectionsRoutes = require('./routes/github-collections');
|
|
264
285
|
|
|
265
286
|
// Initialize chat session manager
|
|
266
287
|
const ChatSessionManager = require('./chat/session-manager');
|
|
267
|
-
chatSessionManager = new ChatSessionManager(db);
|
|
288
|
+
chatSessionManager = new ChatSessionManager(db, config.chat_providers || {});
|
|
268
289
|
app.chatSessionManager = chatSessionManager;
|
|
269
290
|
|
|
270
291
|
// Mount specific routes first to ensure they match before general PR routes
|
|
@@ -278,6 +299,7 @@ async function startServer(sharedDb = null) {
|
|
|
278
299
|
app.use('/', localRoutes);
|
|
279
300
|
app.use('/', setupRoutes);
|
|
280
301
|
app.use('/', mcpRoutes);
|
|
302
|
+
app.use('/', githubCollectionsRoutes);
|
|
281
303
|
app.use('/', prRoutes);
|
|
282
304
|
|
|
283
305
|
// Error handling middleware
|
|
@@ -299,7 +321,11 @@ async function startServer(sharedDb = null) {
|
|
|
299
321
|
// condition where the frontend fetches config before the cache is populated)
|
|
300
322
|
const defaultProvider = config.default_provider || 'claude';
|
|
301
323
|
try {
|
|
324
|
+
// Sequential: checkAllProviders must finish first because it populates
|
|
325
|
+
// the AI provider availability cache that checkAllChatProviders reads
|
|
326
|
+
// (e.g. the pi chat provider calls getCachedAvailability('pi')).
|
|
302
327
|
await checkAllProviders(defaultProvider);
|
|
328
|
+
await checkAllChatProviders();
|
|
303
329
|
} catch (err) {
|
|
304
330
|
console.warn('Provider availability check failed:', err.message);
|
|
305
331
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Safely parse JSON with a fallback value.
|
|
4
|
+
* Useful for database columns that may contain malformed JSON.
|
|
5
|
+
*
|
|
6
|
+
* @param {string|null|undefined} str - The JSON string to parse
|
|
7
|
+
* @param {*} [fallback=null] - Value to return if parsing fails
|
|
8
|
+
* @returns {*} Parsed JSON value or the fallback
|
|
9
|
+
*/
|
|
10
|
+
function safeParseJson(str, fallback = null) {
|
|
11
|
+
if (str == null) return fallback;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(str);
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { safeParseJson };
|