@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.
@@ -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 };
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ module.exports = require('./server');
@@ -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; } };
@@ -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 };