@in-the-loop-labs/pair-review 2.1.1 → 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 +82 -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/css/pr.css +10 -0
- package/public/js/components/ChatPanel.js +160 -99
- package/public/js/components/CouncilProgressModal.js +56 -64
- package/public/js/components/ReviewModal.js +65 -2
- 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 +72 -50
- package/src/database.js +29 -9
- package/src/events/review-events.js +30 -0
- package/src/github/parser.js +29 -2
- package/src/main.js +35 -1
- package/src/protocol-handler.js +122 -0
- package/src/routes/analyses.js +3 -102
- package/src/routes/chat.js +37 -74
- package/src/routes/config.js +64 -5
- 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
|
@@ -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 };
|