@in-the-loop-labs/pair-review 2.2.0 → 2.3.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 +77 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/ChatPanel.js +160 -99
- package/public/js/components/CouncilProgressModal.js +56 -64
- package/public/js/modules/file-comment-manager.js +11 -30
- package/public/js/modules/suggestion-manager.js +2 -23
- package/public/js/pr.js +11 -15
- package/public/js/ws-client.js +155 -0
- package/public/local.html +3 -0
- package/public/pr.html +3 -0
- package/public/setup.html +51 -90
- package/src/ai/analyzer.js +5 -8
- package/src/config.js +70 -49
- package/src/database.js +29 -9
- package/src/events/review-events.js +30 -0
- package/src/routes/analyses.js +3 -102
- package/src/routes/chat.js +37 -74
- package/src/routes/config.js +62 -4
- package/src/routes/context-files.js +1 -1
- package/src/routes/local.js +1 -2
- package/src/routes/mcp.js +1 -1
- package/src/routes/pr.js +1 -2
- package/src/routes/reviews.js +36 -29
- package/src/routes/setup.js +17 -114
- package/src/routes/shared.js +5 -49
- package/src/server.js +4 -0
- package/src/utils/comment-formatter.js +137 -0
- package/src/ws/index.js +2 -0
- package/src/ws/server.js +123 -0
- package/src/sse/review-events.js +0 -46
package/src/routes/setup.js
CHANGED
|
@@ -2,19 +2,17 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Setup Routes
|
|
4
4
|
*
|
|
5
|
-
* Provides API endpoints for the auto-create review flow with
|
|
6
|
-
* updates. Supports both PR-based and local review setup.
|
|
5
|
+
* Provides API endpoints for the auto-create review flow with WebSocket
|
|
6
|
+
* progress updates. Supports both PR-based and local review setup.
|
|
7
7
|
*
|
|
8
8
|
* Endpoints:
|
|
9
9
|
* - POST /api/setup/pr/:owner/:repo/:number - Start PR review setup
|
|
10
|
-
* - GET /api/setup/pr/:owner/:repo/:number/progress - SSE progress for PR setup
|
|
11
10
|
* - POST /api/setup/local - Start local review setup
|
|
12
|
-
* - GET /api/setup/local/:setupId/progress - SSE progress for local setup
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
const express = require('express');
|
|
16
14
|
const crypto = require('crypto');
|
|
17
|
-
const { activeSetups,
|
|
15
|
+
const { activeSetups, broadcastSetupProgress } = require('./shared');
|
|
18
16
|
const { setupPRReview } = require('../setup/pr-setup');
|
|
19
17
|
const { setupLocalReview } = require('../setup/local-setup');
|
|
20
18
|
const { getGitHubToken } = require('../config');
|
|
@@ -25,31 +23,17 @@ const logger = require('../utils/logger');
|
|
|
25
23
|
const router = express.Router();
|
|
26
24
|
|
|
27
25
|
/**
|
|
28
|
-
* Send a
|
|
26
|
+
* Send a setup progress event via WebSocket.
|
|
29
27
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* EventSource `addEventListener(type, ...)` listeners fire correctly.
|
|
28
|
+
* Converts the named event pattern to a WebSocket message with a `type`
|
|
29
|
+
* field so the client can dispatch on `msg.type` (e.g. 'step', 'complete', 'error').
|
|
33
30
|
*
|
|
34
31
|
* @param {string} setupId - Setup operation ID
|
|
35
|
-
* @param {string} eventType -
|
|
32
|
+
* @param {string} eventType - Event type (e.g. 'step', 'complete', 'error')
|
|
36
33
|
* @param {Object} data - JSON-serialisable payload
|
|
37
34
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
if (clients && clients.size > 0) {
|
|
41
|
-
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
42
|
-
clients.forEach(client => {
|
|
43
|
-
try {
|
|
44
|
-
client.write(message);
|
|
45
|
-
} catch (e) {
|
|
46
|
-
clients.delete(client);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
if (clients.size === 0) {
|
|
50
|
-
setupProgressClients.delete(setupId);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
35
|
+
function sendSetupEvent(setupId, eventType, data) {
|
|
36
|
+
broadcastSetupProgress(setupId, { type: eventType, ...data });
|
|
53
37
|
}
|
|
54
38
|
|
|
55
39
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +44,7 @@ function sendSetupSSE(setupId, eventType, data) {
|
|
|
60
44
|
* Initiate an asynchronous PR review setup.
|
|
61
45
|
*
|
|
62
46
|
* Returns immediately with a { setupId } that the client uses to subscribe
|
|
63
|
-
* to
|
|
47
|
+
* to WebSocket progress events. If setup is already in-flight for this PR, the
|
|
64
48
|
* existing setupId is returned. If the PR already exists in the database the
|
|
65
49
|
* response includes `{ existing: true, reviewUrl }` so the client can
|
|
66
50
|
* navigate directly.
|
|
@@ -125,14 +109,14 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
125
109
|
githubToken,
|
|
126
110
|
config,
|
|
127
111
|
onProgress: (progress) => {
|
|
128
|
-
|
|
112
|
+
sendSetupEvent(setupId, 'step', progress);
|
|
129
113
|
}
|
|
130
114
|
});
|
|
131
115
|
|
|
132
|
-
|
|
116
|
+
sendSetupEvent(setupId, 'complete', { reviewUrl: result.reviewUrl, title: result.title });
|
|
133
117
|
} catch (err) {
|
|
134
118
|
logger.error(`PR setup failed for ${setupKey}:`, err);
|
|
135
|
-
|
|
119
|
+
sendSetupEvent(setupId, 'error', { message: err.message });
|
|
136
120
|
} finally {
|
|
137
121
|
activeSetups.delete(setupKey);
|
|
138
122
|
}
|
|
@@ -147,48 +131,6 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
147
131
|
}
|
|
148
132
|
});
|
|
149
133
|
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// GET /api/setup/pr/:owner/:repo/:number/progress
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* SSE endpoint for streaming PR setup progress to the client.
|
|
156
|
-
*
|
|
157
|
-
* The client must pass `?setupId=<id>` as a query parameter (returned from
|
|
158
|
-
* the POST endpoint above).
|
|
159
|
-
*/
|
|
160
|
-
router.get('/api/setup/pr/:owner/:repo/:number/progress', (req, res) => {
|
|
161
|
-
const { setupId } = req.query;
|
|
162
|
-
|
|
163
|
-
if (!setupId) {
|
|
164
|
-
return res.status(400).json({ error: 'Missing setupId query parameter' });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// SSE headers
|
|
168
|
-
res.writeHead(200, {
|
|
169
|
-
'Content-Type': 'text/event-stream',
|
|
170
|
-
'Cache-Control': 'no-cache',
|
|
171
|
-
'Connection': 'keep-alive'
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Register this client
|
|
175
|
-
if (!setupProgressClients.has(setupId)) {
|
|
176
|
-
setupProgressClients.set(setupId, new Set());
|
|
177
|
-
}
|
|
178
|
-
setupProgressClients.get(setupId).add(res);
|
|
179
|
-
|
|
180
|
-
// Clean up on disconnect
|
|
181
|
-
req.on('close', () => {
|
|
182
|
-
const clients = setupProgressClients.get(setupId);
|
|
183
|
-
if (clients) {
|
|
184
|
-
clients.delete(res);
|
|
185
|
-
if (clients.size === 0) {
|
|
186
|
-
setupProgressClients.delete(setupId);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
134
|
// ---------------------------------------------------------------------------
|
|
193
135
|
// POST /api/setup/local
|
|
194
136
|
// ---------------------------------------------------------------------------
|
|
@@ -197,7 +139,7 @@ router.get('/api/setup/pr/:owner/:repo/:number/progress', (req, res) => {
|
|
|
197
139
|
* Initiate an asynchronous local review setup.
|
|
198
140
|
*
|
|
199
141
|
* Expects JSON body `{ path }` with the local directory to review. Returns
|
|
200
|
-
* `{ setupId }` for the client to subscribe to
|
|
142
|
+
* `{ setupId }` for the client to subscribe to WebSocket progress events.
|
|
201
143
|
*/
|
|
202
144
|
router.post('/api/setup/local', async (req, res) => {
|
|
203
145
|
try {
|
|
@@ -224,11 +166,11 @@ router.post('/api/setup/local', async (req, res) => {
|
|
|
224
166
|
db,
|
|
225
167
|
targetPath,
|
|
226
168
|
onProgress: (progress) => {
|
|
227
|
-
|
|
169
|
+
sendSetupEvent(setupId, 'step', progress);
|
|
228
170
|
}
|
|
229
171
|
});
|
|
230
172
|
|
|
231
|
-
|
|
173
|
+
sendSetupEvent(setupId, 'complete', {
|
|
232
174
|
reviewUrl: result.reviewUrl,
|
|
233
175
|
reviewId: result.reviewId,
|
|
234
176
|
existing: result.existing,
|
|
@@ -237,7 +179,7 @@ router.post('/api/setup/local', async (req, res) => {
|
|
|
237
179
|
});
|
|
238
180
|
} catch (err) {
|
|
239
181
|
logger.error(`Local setup failed for ${setupKey}:`, err);
|
|
240
|
-
|
|
182
|
+
sendSetupEvent(setupId, 'error', { message: err.message });
|
|
241
183
|
} finally {
|
|
242
184
|
activeSetups.delete(setupKey);
|
|
243
185
|
}
|
|
@@ -252,43 +194,4 @@ router.post('/api/setup/local', async (req, res) => {
|
|
|
252
194
|
}
|
|
253
195
|
});
|
|
254
196
|
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// GET /api/setup/local/:setupId/progress
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* SSE endpoint for streaming local review setup progress to the client.
|
|
261
|
-
*/
|
|
262
|
-
router.get('/api/setup/local/:setupId/progress', (req, res) => {
|
|
263
|
-
const { setupId } = req.params;
|
|
264
|
-
|
|
265
|
-
if (!setupId) {
|
|
266
|
-
return res.status(400).json({ error: 'Missing setupId parameter' });
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// SSE headers
|
|
270
|
-
res.writeHead(200, {
|
|
271
|
-
'Content-Type': 'text/event-stream',
|
|
272
|
-
'Cache-Control': 'no-cache',
|
|
273
|
-
'Connection': 'keep-alive'
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Register this client
|
|
277
|
-
if (!setupProgressClients.has(setupId)) {
|
|
278
|
-
setupProgressClients.set(setupId, new Set());
|
|
279
|
-
}
|
|
280
|
-
setupProgressClients.get(setupId).add(res);
|
|
281
|
-
|
|
282
|
-
// Clean up on disconnect
|
|
283
|
-
req.on('close', () => {
|
|
284
|
-
const clients = setupProgressClients.get(setupId);
|
|
285
|
-
if (clients) {
|
|
286
|
-
clients.delete(res);
|
|
287
|
-
if (clients.size === 0) {
|
|
288
|
-
setupProgressClients.delete(setupId);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
197
|
module.exports = router;
|
package/src/routes/shared.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const logger = require('../utils/logger');
|
|
10
|
+
const ws = require('../ws');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Custom error class for analysis cancellation
|
|
@@ -27,9 +28,6 @@ const activeAnalyses = new Map();
|
|
|
27
28
|
// Unified map: replaces the previous separate prToAnalysisId and localReviewToAnalysisId maps.
|
|
28
29
|
const reviewToAnalysisId = new Map();
|
|
29
30
|
|
|
30
|
-
// Store SSE clients for real-time progress updates
|
|
31
|
-
const progressClients = new Map();
|
|
32
|
-
|
|
33
31
|
// Store local review diff data keyed by reviewId
|
|
34
32
|
// Using a Map avoids process.env size limits and security concerns
|
|
35
33
|
const localReviewDiffs = new Map();
|
|
@@ -42,10 +40,6 @@ const activeProcesses = new Map();
|
|
|
42
40
|
// Maps setupKey (e.g., "pr:owner/repo/123" or "local:/path") -> { setupId, promise }
|
|
43
41
|
const activeSetups = new Map();
|
|
44
42
|
|
|
45
|
-
// Store SSE clients for setup progress updates
|
|
46
|
-
// Maps setupId -> Set of response objects
|
|
47
|
-
const setupProgressClients = new Map();
|
|
48
|
-
|
|
49
43
|
/**
|
|
50
44
|
* Get the model to use for AI analysis
|
|
51
45
|
* Priority: CLI flag (PAIR_REVIEW_MODEL env var) > config.default_model > 'opus' default
|
|
@@ -115,33 +109,12 @@ function determineCompletionInfo(result) {
|
|
|
115
109
|
}
|
|
116
110
|
|
|
117
111
|
/**
|
|
118
|
-
* Broadcast progress update to all
|
|
112
|
+
* Broadcast progress update to all WebSocket clients subscribed to `analysis:{analysisId}`.
|
|
119
113
|
* @param {string} analysisId - Analysis ID
|
|
120
114
|
* @param {Object} progressData - Progress data to broadcast
|
|
121
115
|
*/
|
|
122
116
|
function broadcastProgress(analysisId, progressData) {
|
|
123
|
-
|
|
124
|
-
if (clients && clients.size > 0) {
|
|
125
|
-
const message = `data: ${JSON.stringify({
|
|
126
|
-
type: 'progress',
|
|
127
|
-
...progressData
|
|
128
|
-
})}\n\n`;
|
|
129
|
-
|
|
130
|
-
// Send to all connected clients
|
|
131
|
-
clients.forEach(client => {
|
|
132
|
-
try {
|
|
133
|
-
client.write(message);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
// Remove dead clients
|
|
136
|
-
clients.delete(client);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Clean up if no clients left
|
|
141
|
-
if (clients.size === 0) {
|
|
142
|
-
progressClients.delete(analysisId);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
117
|
+
ws.broadcast('analysis:' + analysisId, { type: 'progress', ...progressData });
|
|
145
118
|
}
|
|
146
119
|
|
|
147
120
|
/**
|
|
@@ -206,27 +179,12 @@ function isAnalysisCancelled(analysisId) {
|
|
|
206
179
|
}
|
|
207
180
|
|
|
208
181
|
/**
|
|
209
|
-
* Broadcast setup progress to all
|
|
182
|
+
* Broadcast setup progress to all WebSocket clients subscribed to `setup:{setupId}`.
|
|
210
183
|
* @param {string} setupId - Setup operation ID
|
|
211
184
|
* @param {Object} data - Progress data to broadcast
|
|
212
185
|
*/
|
|
213
186
|
function broadcastSetupProgress(setupId, data) {
|
|
214
|
-
|
|
215
|
-
if (clients && clients.size > 0) {
|
|
216
|
-
const message = `data: ${JSON.stringify(data)}\n\n`;
|
|
217
|
-
|
|
218
|
-
clients.forEach(client => {
|
|
219
|
-
try {
|
|
220
|
-
client.write(message);
|
|
221
|
-
} catch (error) {
|
|
222
|
-
clients.delete(client);
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
if (clients.size === 0) {
|
|
227
|
-
setupProgressClients.delete(setupId);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
187
|
+
ws.broadcast('setup:' + setupId, data);
|
|
230
188
|
}
|
|
231
189
|
|
|
232
190
|
/**
|
|
@@ -426,11 +384,9 @@ module.exports = {
|
|
|
426
384
|
CancellationError,
|
|
427
385
|
activeAnalyses,
|
|
428
386
|
reviewToAnalysisId,
|
|
429
|
-
progressClients,
|
|
430
387
|
localReviewDiffs,
|
|
431
388
|
activeProcesses,
|
|
432
389
|
activeSetups,
|
|
433
|
-
setupProgressClients,
|
|
434
390
|
getModel,
|
|
435
391
|
determineCompletionInfo,
|
|
436
392
|
broadcastProgress,
|
package/src/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./data
|
|
|
6
6
|
const { normalizeRepository } = require('./utils/paths');
|
|
7
7
|
const { applyConfigOverrides, checkAllProviders } = require('./ai');
|
|
8
8
|
const logger = require('./utils/logger');
|
|
9
|
+
const { attachWebSocket, closeAll: closeAllWS } = require('./ws');
|
|
9
10
|
|
|
10
11
|
let db = null;
|
|
11
12
|
let server = null;
|
|
@@ -305,6 +306,7 @@ async function startServer(sharedDb = null) {
|
|
|
305
306
|
|
|
306
307
|
server = app.listen(port, () => {
|
|
307
308
|
console.log(`Server running on http://localhost:${port}`);
|
|
309
|
+
attachWebSocket(server);
|
|
308
310
|
});
|
|
309
311
|
|
|
310
312
|
server.on('error', (error) => {
|
|
@@ -343,6 +345,8 @@ async function gracefulShutdown(signal) {
|
|
|
343
345
|
}
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
closeAllWS();
|
|
349
|
+
|
|
346
350
|
if (server) {
|
|
347
351
|
server.close(() => {
|
|
348
352
|
if (db) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Shared comment formatter for adopted AI suggestions.
|
|
4
|
+
* Provides configurable formatting with preset templates.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { getEmoji } = require('./category-emoji');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Preset format templates for adopted comments.
|
|
11
|
+
* Template placeholders: {emoji}, {category}, {title}, {description}, {suggestion}
|
|
12
|
+
* Conditional sections: {?field}...{/field} — content is kept when field is truthy, stripped when falsy.
|
|
13
|
+
*/
|
|
14
|
+
const PRESETS = {
|
|
15
|
+
legacy: '{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}',
|
|
16
|
+
minimal: '[{category}] {description}{?suggestion}\n\n{suggestion}{/suggestion}',
|
|
17
|
+
plain: '{description}{?suggestion}\n\n{suggestion}{/suggestion}',
|
|
18
|
+
'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}'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a config value into a format configuration object.
|
|
24
|
+
* @param {string|Object|undefined} config - Preset name string, custom config object, or undefined
|
|
25
|
+
* @returns {{ template: string, emojiOverrides: Object, categoryOverrides: Object }}
|
|
26
|
+
*/
|
|
27
|
+
function resolveFormat(config) {
|
|
28
|
+
if (!config || typeof config === 'string') {
|
|
29
|
+
const presetName = config || 'legacy';
|
|
30
|
+
const template = PRESETS[presetName] || PRESETS.legacy;
|
|
31
|
+
return { template, emojiOverrides: {}, categoryOverrides: {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Custom object config
|
|
35
|
+
return {
|
|
36
|
+
template: config.template || PRESETS.legacy,
|
|
37
|
+
emojiOverrides: config.emojiOverrides || {},
|
|
38
|
+
categoryOverrides: config.categoryOverrides || {}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Capitalize a hyphenated category name.
|
|
44
|
+
* e.g., 'code-style' -> 'Code Style', 'bug' -> 'Bug'
|
|
45
|
+
* @param {string} category
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function capitalizeCategory(category) {
|
|
49
|
+
return category
|
|
50
|
+
.split('-')
|
|
51
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
52
|
+
.join(' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Process conditional sections in a template.
|
|
57
|
+
* Syntax: {?fieldName}content{/fieldName}
|
|
58
|
+
* When the field value is truthy, the delimiters are stripped and content is kept.
|
|
59
|
+
* When the field value is falsy/empty/undefined, the entire block is removed.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} template - Template with conditional sections
|
|
62
|
+
* @param {Object} values - Map of field names to their values
|
|
63
|
+
* @returns {string} Template with conditional sections resolved
|
|
64
|
+
*/
|
|
65
|
+
function processConditionalSections(template, values) {
|
|
66
|
+
return template.replace(/\{\?(\w+)\}([\s\S]*?)\{\/\1\}/g, (match, fieldName, content) => {
|
|
67
|
+
const value = values[fieldName];
|
|
68
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
69
|
+
return content;
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Format an adopted comment using the given format configuration.
|
|
77
|
+
* Handles legacy data where suggestion_text was concatenated into body.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ body: string, suggestionText?: string, category?: string, title?: string }} fields
|
|
80
|
+
* @param {{ template: string, emojiOverrides: Object, categoryOverrides: Object }} formatConfig
|
|
81
|
+
* @returns {string} Formatted comment text
|
|
82
|
+
*/
|
|
83
|
+
function formatAdoptedComment(fields, formatConfig) {
|
|
84
|
+
const { body, title } = fields;
|
|
85
|
+
let { category, suggestionText } = fields;
|
|
86
|
+
|
|
87
|
+
if (!category) {
|
|
88
|
+
return body || '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
category = category.toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Legacy handling: if no separate suggestionText, try to split from body
|
|
94
|
+
let description = body || '';
|
|
95
|
+
if (!suggestionText && description.includes('\n\n**Suggestion:** ')) {
|
|
96
|
+
const splitIndex = description.indexOf('\n\n**Suggestion:** ');
|
|
97
|
+
suggestionText = description.slice(splitIndex + '\n\n**Suggestion:** '.length);
|
|
98
|
+
description = description.slice(0, splitIndex);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const config = formatConfig || resolveFormat();
|
|
102
|
+
|
|
103
|
+
// Resolve emoji from original category BEFORE applying overrides,
|
|
104
|
+
// so overridden categories keep the original category's emoji
|
|
105
|
+
const emoji = config.emojiOverrides?.[category] || getEmoji(category);
|
|
106
|
+
|
|
107
|
+
// Apply category overrides (e.g., "bug" -> "defect")
|
|
108
|
+
if (config.categoryOverrides && config.categoryOverrides[category]) {
|
|
109
|
+
category = config.categoryOverrides[category];
|
|
110
|
+
}
|
|
111
|
+
const capitalizedCategory = capitalizeCategory(category);
|
|
112
|
+
|
|
113
|
+
// Process conditional sections first, then replace individual placeholders
|
|
114
|
+
const fieldValues = {
|
|
115
|
+
suggestion: suggestionText || '',
|
|
116
|
+
title: title || '',
|
|
117
|
+
emoji,
|
|
118
|
+
category: capitalizedCategory,
|
|
119
|
+
description
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let result = processConditionalSections(config.template, fieldValues);
|
|
123
|
+
|
|
124
|
+
// Replace placeholders
|
|
125
|
+
result = result.replace(/\{emoji\}/g, emoji);
|
|
126
|
+
result = result.replace(/\{category\}/g, capitalizedCategory);
|
|
127
|
+
result = result.replace(/\{title\}/g, title || '');
|
|
128
|
+
result = result.replace(/\{description\}/g, description);
|
|
129
|
+
result = result.replace(/\{suggestion\}/g, suggestionText || '');
|
|
130
|
+
|
|
131
|
+
// Ensure code fences start on their own line
|
|
132
|
+
result = result.replace(/([^\n])(```)/g, '$1\n$2');
|
|
133
|
+
|
|
134
|
+
return result.trimEnd();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { PRESETS, resolveFormat, formatAdoptedComment, capitalizeCategory, processConditionalSections };
|
package/src/ws/index.js
ADDED
package/src/ws/server.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const { WebSocketServer } = require('ws');
|
|
3
|
+
const logger = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const HEARTBEAT_INTERVAL = 30000;
|
|
6
|
+
|
|
7
|
+
let wss = null;
|
|
8
|
+
let heartbeatTimer = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attach a WebSocket server to an existing HTTP server.
|
|
12
|
+
* Operates in noServer mode, handling upgrade requests on the /ws path only.
|
|
13
|
+
* @param {import('http').Server} httpServer
|
|
14
|
+
*/
|
|
15
|
+
function attachWebSocket(httpServer) {
|
|
16
|
+
wss = new WebSocketServer({ noServer: true });
|
|
17
|
+
|
|
18
|
+
httpServer.on('upgrade', (request, socket, head) => {
|
|
19
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
20
|
+
if (pathname !== '/ws') {
|
|
21
|
+
socket.destroy();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
26
|
+
wss.emit('connection', ws, request);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
wss.on('connection', (ws) => {
|
|
31
|
+
ws._topics = new Set();
|
|
32
|
+
ws.isAlive = true;
|
|
33
|
+
|
|
34
|
+
ws.on('pong', () => {
|
|
35
|
+
ws.isAlive = true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
ws.on('message', (data) => {
|
|
39
|
+
let msg;
|
|
40
|
+
try {
|
|
41
|
+
msg = JSON.parse(data);
|
|
42
|
+
} catch {
|
|
43
|
+
logger.warn('WS: received non-JSON message');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { action, topic } = msg;
|
|
48
|
+
if (!topic) return;
|
|
49
|
+
|
|
50
|
+
if (action === 'subscribe') {
|
|
51
|
+
ws._topics.add(topic);
|
|
52
|
+
} else if (action === 'unsubscribe') {
|
|
53
|
+
ws._topics.delete(topic);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
ws.on('close', () => {
|
|
58
|
+
ws._topics.clear();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
ws.on('error', (err) => {
|
|
62
|
+
logger.warn(`WS: client error: ${err.message}`);
|
|
63
|
+
ws._topics.clear();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Heartbeat: ping every HEARTBEAT_INTERVAL, terminate dead connections
|
|
68
|
+
heartbeatTimer = setInterval(() => {
|
|
69
|
+
wss.clients.forEach((ws) => {
|
|
70
|
+
if (!ws.isAlive) {
|
|
71
|
+
logger.debug('WS: terminating unresponsive client');
|
|
72
|
+
ws.terminate();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
ws.isAlive = false;
|
|
76
|
+
ws.ping();
|
|
77
|
+
});
|
|
78
|
+
}, HEARTBEAT_INTERVAL);
|
|
79
|
+
|
|
80
|
+
logger.info('WebSocket server attached on /ws');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Broadcast a payload to all clients subscribed to the given topic.
|
|
85
|
+
* @param {string} topic
|
|
86
|
+
* @param {object} payload
|
|
87
|
+
*/
|
|
88
|
+
function broadcast(topic, payload) {
|
|
89
|
+
if (!wss) return;
|
|
90
|
+
|
|
91
|
+
const message = JSON.stringify({ ...payload, topic });
|
|
92
|
+
|
|
93
|
+
wss.clients.forEach((ws) => {
|
|
94
|
+
if (ws.readyState === ws.OPEN && ws._topics && ws._topics.has(topic)) {
|
|
95
|
+
try {
|
|
96
|
+
ws.send(message);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.debug(`WS: failed to send to client: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Close all connections and shut down the WebSocket server.
|
|
106
|
+
*/
|
|
107
|
+
function closeAll() {
|
|
108
|
+
if (heartbeatTimer) {
|
|
109
|
+
clearInterval(heartbeatTimer);
|
|
110
|
+
heartbeatTimer = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!wss) return;
|
|
114
|
+
|
|
115
|
+
wss.clients.forEach((ws) => {
|
|
116
|
+
ws.terminate();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
wss.close();
|
|
120
|
+
wss = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { attachWebSocket, broadcast, closeAll, get _wss() { return wss; } };
|
package/src/sse/review-events.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
-
/**
|
|
3
|
-
* Shared SSE client registry and review-scoped event broadcaster.
|
|
4
|
-
*
|
|
5
|
-
* All SSE connections (chat, analysis, etc.) share a single Set of
|
|
6
|
-
* Express response objects. broadcastReviewEvent sends review-level
|
|
7
|
-
* events (as opposed to session-level events handled in chat.js).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const logger = require('../utils/logger');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Connected SSE clients shared across all route modules.
|
|
14
|
-
* Each entry is an Express response object with an open SSE connection.
|
|
15
|
-
* @type {Set<import('express').Response>}
|
|
16
|
-
*/
|
|
17
|
-
const sseClients = new Set();
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Broadcast a review-scoped SSE event to all connected clients.
|
|
21
|
-
* Optionally includes a `sourceClientId` so the originating browser tab
|
|
22
|
-
* can recognise (and skip) its own echo.
|
|
23
|
-
*
|
|
24
|
-
* @param {number} reviewId - Review ID to include in the event
|
|
25
|
-
* @param {Object} payload - Event data (must include at minimum a `type` field)
|
|
26
|
-
* @param {Object} [options]
|
|
27
|
-
* @param {string} [options.sourceClientId] - Client ID of the tab that triggered the mutation
|
|
28
|
-
*/
|
|
29
|
-
function broadcastReviewEvent(reviewId, payload, options = {}) {
|
|
30
|
-
const envelope = { ...payload, reviewId };
|
|
31
|
-
if (options.sourceClientId) {
|
|
32
|
-
envelope.sourceClientId = options.sourceClientId;
|
|
33
|
-
}
|
|
34
|
-
const data = JSON.stringify(envelope);
|
|
35
|
-
for (const client of sseClients) {
|
|
36
|
-
try {
|
|
37
|
-
client.write(`data: ${data}\n\n`);
|
|
38
|
-
} catch {
|
|
39
|
-
// Client disconnected — remove from set
|
|
40
|
-
sseClients.delete(client);
|
|
41
|
-
logger.debug('[ReviewEvents] Removed disconnected SSE client');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
module.exports = { sseClients, broadcastReviewEvent };
|