opencode-pilot 0.24.3 → 0.24.5
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/Formula/opencode-pilot.rb +2 -2
- package/package.json +1 -1
- package/service/actions.js +51 -18
- package/service/poll-service.js +3 -0
- package/service/poller.js +20 -3
- package/service/utils.js +82 -28
- package/test/unit/actions.test.js +97 -0
- package/test/unit/poller.test.js +83 -4
- package/test/unit/utils.test.js +65 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
class OpencodePilot < Formula
|
|
2
2
|
desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
|
|
3
3
|
homepage "https://github.com/athal7/opencode-pilot"
|
|
4
|
-
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.
|
|
5
|
-
sha256 "
|
|
4
|
+
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.4.tar.gz"
|
|
5
|
+
sha256 "7a697b9543877054b0ae42a4bbfff69f1bb9b91c3c724cbee9cc256bd75d2e83"
|
|
6
6
|
license "MIT"
|
|
7
7
|
|
|
8
8
|
depends_on "node"
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -14,6 +14,12 @@ import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory }
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import os from "os";
|
|
16
16
|
|
|
17
|
+
// Safety timeout for the server to return HTTP response headers.
|
|
18
|
+
// The /command endpoint can take 30-45s to return headers because it does
|
|
19
|
+
// work before responding. The /message endpoint returns headers in ~1ms.
|
|
20
|
+
// These are generous upper bounds — if exceeded, the server is genuinely stuck.
|
|
21
|
+
export const HEADER_TIMEOUT_MS = 60_000;
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Parse a slash command from the beginning of a prompt
|
|
19
25
|
* Returns null if the prompt doesn't start with a slash command
|
|
@@ -519,6 +525,7 @@ export function selectBestSession(sessions, statuses) {
|
|
|
519
525
|
*/
|
|
520
526
|
export async function sendMessageToSession(serverUrl, sessionId, directory, prompt, options = {}) {
|
|
521
527
|
const fetchFn = options.fetch || fetch;
|
|
528
|
+
const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
|
|
522
529
|
|
|
523
530
|
try {
|
|
524
531
|
// Step 1: Update session title if provided
|
|
@@ -536,9 +543,16 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
|
|
|
536
543
|
// Step 2: Check if the prompt starts with a slash command
|
|
537
544
|
const parsedCommand = parseSlashCommand(prompt);
|
|
538
545
|
|
|
539
|
-
//
|
|
546
|
+
// Wait for response headers (confirming server accepted the request),
|
|
547
|
+
// then abort the body stream. Safety timeout catches stuck requests.
|
|
540
548
|
const controller = new AbortController();
|
|
541
|
-
|
|
549
|
+
let headersReceived = false;
|
|
550
|
+
const timeoutId = setTimeout(() => {
|
|
551
|
+
if (!headersReceived) {
|
|
552
|
+
debug(`sendMessageToSession: safety timeout - server did not return headers within ${headerTimeout}ms for session ${sessionId}`);
|
|
553
|
+
controller.abort();
|
|
554
|
+
}
|
|
555
|
+
}, headerTimeout);
|
|
542
556
|
|
|
543
557
|
try {
|
|
544
558
|
let response;
|
|
@@ -580,6 +594,8 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
|
|
|
580
594
|
});
|
|
581
595
|
}
|
|
582
596
|
|
|
597
|
+
// Headers received — cancel the safety timeout
|
|
598
|
+
headersReceived = true;
|
|
583
599
|
clearTimeout(timeoutId);
|
|
584
600
|
|
|
585
601
|
if (!response.ok) {
|
|
@@ -587,14 +603,17 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
|
|
|
587
603
|
throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
|
|
588
604
|
}
|
|
589
605
|
|
|
590
|
-
debug(`sendMessageToSession:
|
|
606
|
+
debug(`sendMessageToSession: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${sessionId}`);
|
|
591
607
|
} catch (abortErr) {
|
|
592
608
|
clearTimeout(timeoutId);
|
|
593
|
-
if (abortErr.name === 'AbortError') {
|
|
594
|
-
|
|
595
|
-
} else {
|
|
596
|
-
throw abortErr;
|
|
609
|
+
if (abortErr.name === 'AbortError' && !headersReceived) {
|
|
610
|
+
throw new Error(`Server did not confirm acceptance within ${headerTimeout / 1000}s for session ${sessionId}`);
|
|
597
611
|
}
|
|
612
|
+
throw abortErr;
|
|
613
|
+
} finally {
|
|
614
|
+
// Abort the body stream — we don't need the streaming response content.
|
|
615
|
+
// Done in finally to ensure cleanup regardless of success/error path.
|
|
616
|
+
controller.abort();
|
|
598
617
|
}
|
|
599
618
|
|
|
600
619
|
return {
|
|
@@ -670,6 +689,7 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
|
670
689
|
*/
|
|
671
690
|
export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
|
|
672
691
|
const fetchFn = options.fetch || fetch;
|
|
692
|
+
const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
|
|
673
693
|
|
|
674
694
|
let session = null;
|
|
675
695
|
|
|
@@ -706,11 +726,18 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
706
726
|
// Step 3: Check if the prompt starts with a slash command
|
|
707
727
|
const parsedCommand = parseSlashCommand(prompt);
|
|
708
728
|
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
729
|
+
// Wait for the server to return response headers (confirming it accepted the
|
|
730
|
+
// request), then abort the body stream. The /command endpoint can take 30-45s
|
|
731
|
+
// to return headers — we must NOT abort before that or we can't tell if the
|
|
732
|
+
// server actually accepted. A generous safety timeout catches truly stuck requests.
|
|
712
733
|
const controller = new AbortController();
|
|
713
|
-
|
|
734
|
+
let headersReceived = false;
|
|
735
|
+
const timeoutId = setTimeout(() => {
|
|
736
|
+
if (!headersReceived) {
|
|
737
|
+
debug(`createSessionViaApi: safety timeout - server did not return headers within ${headerTimeout}ms for session ${session.id}`);
|
|
738
|
+
controller.abort();
|
|
739
|
+
}
|
|
740
|
+
}, headerTimeout);
|
|
714
741
|
|
|
715
742
|
try {
|
|
716
743
|
let response;
|
|
@@ -755,6 +782,8 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
755
782
|
});
|
|
756
783
|
}
|
|
757
784
|
|
|
785
|
+
// Headers received — cancel the safety timeout
|
|
786
|
+
headersReceived = true;
|
|
758
787
|
clearTimeout(timeoutId);
|
|
759
788
|
|
|
760
789
|
if (!response.ok) {
|
|
@@ -762,16 +791,19 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
762
791
|
throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
|
|
763
792
|
}
|
|
764
793
|
|
|
765
|
-
debug(`createSessionViaApi:
|
|
794
|
+
debug(`createSessionViaApi: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${session.id}`);
|
|
766
795
|
} catch (abortErr) {
|
|
767
796
|
clearTimeout(timeoutId);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
} else {
|
|
773
|
-
throw abortErr;
|
|
797
|
+
if (abortErr.name === 'AbortError' && !headersReceived) {
|
|
798
|
+
// Safety timeout fired before headers arrived.
|
|
799
|
+
// The server may or may not have accepted the request — we don't know.
|
|
800
|
+
throw new Error(`Server did not confirm acceptance within ${headerTimeout / 1000}s for session ${session.id}`);
|
|
774
801
|
}
|
|
802
|
+
throw abortErr;
|
|
803
|
+
} finally {
|
|
804
|
+
// Abort the body stream — we don't need the streaming response content.
|
|
805
|
+
// Done in finally to ensure cleanup regardless of success/error path.
|
|
806
|
+
controller.abort();
|
|
775
807
|
}
|
|
776
808
|
|
|
777
809
|
return {
|
|
@@ -903,6 +935,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
|
|
|
903
935
|
sessionId: result.sessionId,
|
|
904
936
|
directory: cwd,
|
|
905
937
|
error: result.error,
|
|
938
|
+
warning: result.warning,
|
|
906
939
|
};
|
|
907
940
|
}
|
|
908
941
|
|
package/service/poll-service.js
CHANGED
|
@@ -310,6 +310,9 @@ export async function pollOnce(options = {}) {
|
|
|
310
310
|
// Store attention state for detecting new feedback on PRs
|
|
311
311
|
// _has_attention is boolean for enriched items, undefined for non-PR sources
|
|
312
312
|
hasAttention: item._has_attention ?? null,
|
|
313
|
+
// Store latest feedback timestamp for detecting new reviews on PRs
|
|
314
|
+
// that were already processed with existing feedback (true -> true)
|
|
315
|
+
latestFeedbackAt: item._latest_feedback_at ?? null,
|
|
313
316
|
dedupKeys: dedupKeys.length > 0 ? dedupKeys : undefined,
|
|
314
317
|
});
|
|
315
318
|
}
|
package/service/poller.js
CHANGED
|
@@ -12,7 +12,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
|
12
12
|
import fs from "fs";
|
|
13
13
|
import path from "path";
|
|
14
14
|
import os from "os";
|
|
15
|
-
import { getNestedValue, hasNonBotFeedback, extractIssueRefs } from "./utils.js";
|
|
15
|
+
import { getNestedValue, hasNonBotFeedback, getLatestFeedbackTimestamp, extractIssueRefs } from "./utils.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Expand template string with item fields
|
|
@@ -809,6 +809,7 @@ export function detectStacks(items) {
|
|
|
809
809
|
export function computeAttentionLabels(items, source) {
|
|
810
810
|
return items.map(item => {
|
|
811
811
|
const reasons = [];
|
|
812
|
+
let latestFeedbackAt = null;
|
|
812
813
|
|
|
813
814
|
// Check for merge conflicts
|
|
814
815
|
if (item._mergeable === 'CONFLICTING') {
|
|
@@ -821,6 +822,8 @@ export function computeAttentionLabels(items, source) {
|
|
|
821
822
|
const authorUsername = item.user?.login || item.author?.login;
|
|
822
823
|
if (hasNonBotFeedback(item._comments, authorUsername)) {
|
|
823
824
|
reasons.push('Feedback');
|
|
825
|
+
// Track the latest feedback timestamp for detecting new reviews
|
|
826
|
+
latestFeedbackAt = getLatestFeedbackTimestamp(item._comments, authorUsername);
|
|
824
827
|
}
|
|
825
828
|
}
|
|
826
829
|
|
|
@@ -831,6 +834,7 @@ export function computeAttentionLabels(items, source) {
|
|
|
831
834
|
...item,
|
|
832
835
|
_attention_label: label,
|
|
833
836
|
_has_attention: reasons.length > 0,
|
|
837
|
+
_latest_feedback_at: latestFeedbackAt,
|
|
834
838
|
};
|
|
835
839
|
});
|
|
836
840
|
}
|
|
@@ -1253,15 +1257,28 @@ export function createPoller(options = {}) {
|
|
|
1253
1257
|
}
|
|
1254
1258
|
|
|
1255
1259
|
// Handle attention field (detect new feedback on PRs)
|
|
1256
|
-
//
|
|
1260
|
+
// Triggers when:
|
|
1261
|
+
// 1. Attention changes from false to true (new feedback on a clean PR)
|
|
1262
|
+
// 2. Attention stays true but latest feedback is newer (re-review or additional feedback)
|
|
1257
1263
|
if (field === 'attention') {
|
|
1258
1264
|
const storedHasAttention = meta.hasAttention;
|
|
1259
1265
|
const currentHasAttention = item._has_attention;
|
|
1260
1266
|
|
|
1261
|
-
//
|
|
1267
|
+
// Trigger if attention changed false -> true
|
|
1262
1268
|
if (storedHasAttention === false && currentHasAttention === true) {
|
|
1263
1269
|
return true;
|
|
1264
1270
|
}
|
|
1271
|
+
|
|
1272
|
+
// Trigger if attention stayed true but there's newer feedback
|
|
1273
|
+
// This catches re-reviews and additional feedback on PRs already processed with feedback
|
|
1274
|
+
if (storedHasAttention === true && currentHasAttention === true) {
|
|
1275
|
+
const storedFeedbackAt = meta.latestFeedbackAt;
|
|
1276
|
+
const currentFeedbackAt = item._latest_feedback_at;
|
|
1277
|
+
|
|
1278
|
+
if (storedFeedbackAt && currentFeedbackAt && currentFeedbackAt > storedFeedbackAt) {
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1265
1282
|
}
|
|
1266
1283
|
}
|
|
1267
1284
|
|
package/service/utils.js
CHANGED
|
@@ -192,34 +192,7 @@ export function hasNonBotFeedback(comments, authorUsername) {
|
|
|
192
192
|
const authorLower = authorUsername?.toLowerCase();
|
|
193
193
|
|
|
194
194
|
for (const comment of comments) {
|
|
195
|
-
|
|
196
|
-
if (!user) continue;
|
|
197
|
-
|
|
198
|
-
const username = user.login;
|
|
199
|
-
const userType = user.type;
|
|
200
|
-
|
|
201
|
-
// Skip if it's a bot (but Copilot is NOT in bot list, so Copilot reviews are kept)
|
|
202
|
-
if (isBot(username, userType)) continue;
|
|
203
|
-
|
|
204
|
-
// For author's own feedback, apply special rules
|
|
205
|
-
if (authorLower && username?.toLowerCase() === authorLower) {
|
|
206
|
-
// Author's PR reviews → trigger
|
|
207
|
-
if (isPrReview(comment)) {
|
|
208
|
-
// Continue to check if it's actionable (not approval-only)
|
|
209
|
-
}
|
|
210
|
-
// Author's inline comments (standalone only) → trigger
|
|
211
|
-
else if (isInlineComment(comment)) {
|
|
212
|
-
if (isReply(comment)) continue; // Skip replies
|
|
213
|
-
// Standalone inline comment - continue to actionable check
|
|
214
|
-
}
|
|
215
|
-
// Author's top-level comments → ignore
|
|
216
|
-
else {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Skip approval-only reviews (no actionable feedback)
|
|
222
|
-
if (isApprovalOnly(comment)) continue;
|
|
195
|
+
if (!isActionableFeedback(comment, authorLower)) continue;
|
|
223
196
|
|
|
224
197
|
// Found actionable feedback
|
|
225
198
|
return true;
|
|
@@ -227,3 +200,84 @@ export function hasNonBotFeedback(comments, authorUsername) {
|
|
|
227
200
|
|
|
228
201
|
return false;
|
|
229
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if a single comment/review is actionable feedback
|
|
206
|
+
*
|
|
207
|
+
* Extracted from hasNonBotFeedback to allow reuse in getLatestFeedbackTimestamp.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} comment - Comment or review object
|
|
210
|
+
* @param {string} [authorLower] - Lowercase author username
|
|
211
|
+
* @returns {boolean} True if the comment is actionable feedback
|
|
212
|
+
*/
|
|
213
|
+
function isActionableFeedback(comment, authorLower) {
|
|
214
|
+
const user = comment.user;
|
|
215
|
+
if (!user) return false;
|
|
216
|
+
|
|
217
|
+
const username = user.login;
|
|
218
|
+
const userType = user.type;
|
|
219
|
+
|
|
220
|
+
// Skip if it's a bot (but Copilot is NOT in bot list, so Copilot reviews are kept)
|
|
221
|
+
if (isBot(username, userType)) return false;
|
|
222
|
+
|
|
223
|
+
// For author's own feedback, apply special rules
|
|
224
|
+
if (authorLower && username?.toLowerCase() === authorLower) {
|
|
225
|
+
// Author's PR reviews → trigger
|
|
226
|
+
if (isPrReview(comment)) {
|
|
227
|
+
// Continue to check if it's actionable (not approval-only)
|
|
228
|
+
}
|
|
229
|
+
// Author's inline comments (standalone only) → trigger
|
|
230
|
+
else if (isInlineComment(comment)) {
|
|
231
|
+
if (isReply(comment)) return false; // Skip replies
|
|
232
|
+
// Standalone inline comment - continue to actionable check
|
|
233
|
+
}
|
|
234
|
+
// Author's top-level comments → ignore
|
|
235
|
+
else {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Skip approval-only reviews (no actionable feedback)
|
|
241
|
+
if (isApprovalOnly(comment)) return false;
|
|
242
|
+
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get the timestamp of the latest actionable feedback on a PR/issue
|
|
248
|
+
*
|
|
249
|
+
* Uses the same filtering logic as hasNonBotFeedback to identify actionable
|
|
250
|
+
* comments, then returns the most recent timestamp. This allows detecting
|
|
251
|
+
* new feedback even when the PR was already processed with existing feedback.
|
|
252
|
+
*
|
|
253
|
+
* GitHub API timestamp fields:
|
|
254
|
+
* - PR reviews: submitted_at
|
|
255
|
+
* - PR review comments (inline): updated_at, created_at
|
|
256
|
+
* - Issue comments: updated_at, created_at
|
|
257
|
+
*
|
|
258
|
+
* @param {Array} comments - Array of comment/review objects
|
|
259
|
+
* @param {string} authorUsername - Username of the PR/issue author
|
|
260
|
+
* @returns {string|null} ISO timestamp of latest feedback, or null if none
|
|
261
|
+
*/
|
|
262
|
+
export function getLatestFeedbackTimestamp(comments, authorUsername) {
|
|
263
|
+
if (!comments || !Array.isArray(comments) || comments.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const authorLower = authorUsername?.toLowerCase();
|
|
268
|
+
let latest = null;
|
|
269
|
+
|
|
270
|
+
for (const comment of comments) {
|
|
271
|
+
if (!isActionableFeedback(comment, authorLower)) continue;
|
|
272
|
+
|
|
273
|
+
// PR reviews use submitted_at, comments use updated_at or created_at
|
|
274
|
+
const timestamp = comment.submitted_at || comment.updated_at || comment.created_at;
|
|
275
|
+
if (!timestamp) continue;
|
|
276
|
+
|
|
277
|
+
if (!latest || timestamp > latest) {
|
|
278
|
+
latest = timestamp;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return latest;
|
|
283
|
+
}
|
|
@@ -1263,6 +1263,103 @@ Check for bugs and security issues.`;
|
|
|
1263
1263
|
});
|
|
1264
1264
|
});
|
|
1265
1265
|
|
|
1266
|
+
describe('createSessionViaApi timeout handling', () => {
|
|
1267
|
+
test('treats safety timeout as error with warning (not silent success)', async () => {
|
|
1268
|
+
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
1269
|
+
|
|
1270
|
+
const mockSessionId = 'ses_timeout123';
|
|
1271
|
+
|
|
1272
|
+
const mockFetch = async (url, opts) => {
|
|
1273
|
+
const urlObj = new URL(url);
|
|
1274
|
+
|
|
1275
|
+
// Session creation succeeds
|
|
1276
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
1277
|
+
return {
|
|
1278
|
+
ok: true,
|
|
1279
|
+
json: async () => ({ id: mockSessionId }),
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Title update succeeds
|
|
1284
|
+
if (opts?.method === 'PATCH') {
|
|
1285
|
+
return { ok: true, json: async () => ({}) };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Message endpoint: simulate server never returning headers
|
|
1289
|
+
// by waiting until the AbortSignal fires
|
|
1290
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1291
|
+
return new Promise((resolve, reject) => {
|
|
1292
|
+
if (opts.signal) {
|
|
1293
|
+
opts.signal.addEventListener('abort', () => {
|
|
1294
|
+
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const result = await createSessionViaApi(
|
|
1304
|
+
'http://localhost:4096',
|
|
1305
|
+
'/path/to/project',
|
|
1306
|
+
'Fix the bug',
|
|
1307
|
+
{ fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
|
|
1308
|
+
);
|
|
1309
|
+
|
|
1310
|
+
// Should return success (session was created) but WITH a warning
|
|
1311
|
+
assert.ok(result.success, 'Should return success because session was created');
|
|
1312
|
+
assert.strictEqual(result.sessionId, mockSessionId, 'Should return session ID');
|
|
1313
|
+
assert.ok(result.warning, 'Should include warning about timeout');
|
|
1314
|
+
assert.ok(result.warning.includes('did not confirm acceptance'), 'Warning should mention acceptance timeout');
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test('aborts body stream after receiving 200 headers', async () => {
|
|
1318
|
+
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
1319
|
+
|
|
1320
|
+
const mockSessionId = 'ses_headers123';
|
|
1321
|
+
let bodyAborted = false;
|
|
1322
|
+
|
|
1323
|
+
const mockFetch = async (url, opts) => {
|
|
1324
|
+
const urlObj = new URL(url);
|
|
1325
|
+
|
|
1326
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
1327
|
+
return {
|
|
1328
|
+
ok: true,
|
|
1329
|
+
json: async () => ({ id: mockSessionId }),
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1334
|
+
// Track when the signal aborts (should happen after we return 200)
|
|
1335
|
+
if (opts.signal) {
|
|
1336
|
+
opts.signal.addEventListener('abort', () => {
|
|
1337
|
+
bodyAborted = true;
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
// Return 200 immediately (simulating headers received)
|
|
1341
|
+
return {
|
|
1342
|
+
ok: true,
|
|
1343
|
+
json: async () => ({ success: true }),
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
const result = await createSessionViaApi(
|
|
1351
|
+
'http://localhost:4096',
|
|
1352
|
+
'/path/to/project',
|
|
1353
|
+
'Fix the bug',
|
|
1354
|
+
{ fetch: mockFetch }
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1357
|
+
assert.ok(result.success, 'Should succeed');
|
|
1358
|
+
assert.ok(!result.warning, 'Should NOT have a warning');
|
|
1359
|
+
assert.ok(bodyAborted, 'Should have aborted the body stream after receiving headers');
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1266
1363
|
describe('sendMessageToSession slash command routing', () => {
|
|
1267
1364
|
test('uses /command endpoint for slash commands', async () => {
|
|
1268
1365
|
const { sendMessageToSession } = await import('../../service/actions.js');
|
package/test/unit/poller.test.js
CHANGED
|
@@ -813,23 +813,65 @@ describe('poller.js', () => {
|
|
|
813
813
|
);
|
|
814
814
|
});
|
|
815
815
|
|
|
816
|
-
test('shouldReprocess returns false when attention stays true', async () => {
|
|
816
|
+
test('shouldReprocess returns false when attention stays true with same feedback timestamp', async () => {
|
|
817
817
|
const { createPoller } = await import('../../service/poller.js');
|
|
818
818
|
|
|
819
819
|
const poller = createPoller({ stateFile });
|
|
820
|
-
// Item was processed with attention
|
|
820
|
+
// Item was processed with attention and a feedback timestamp
|
|
821
|
+
poller.markProcessed('pr-1', {
|
|
822
|
+
source: 'my-prs-attention',
|
|
823
|
+
itemState: 'open',
|
|
824
|
+
hasAttention: true,
|
|
825
|
+
latestFeedbackAt: '2026-01-15T10:00:00Z'
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// Item still has attention with the same feedback timestamp
|
|
829
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: true, _latest_feedback_at: '2026-01-15T10:00:00Z' };
|
|
830
|
+
assert.strictEqual(
|
|
831
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
832
|
+
false,
|
|
833
|
+
'Should NOT reprocess when attention stays true with same feedback'
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test('shouldReprocess returns true when attention stays true but feedback is newer (re-review)', async () => {
|
|
838
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
839
|
+
|
|
840
|
+
const poller = createPoller({ stateFile });
|
|
841
|
+
// Item was processed with attention and a feedback timestamp
|
|
842
|
+
poller.markProcessed('pr-1', {
|
|
843
|
+
source: 'my-prs-attention',
|
|
844
|
+
itemState: 'open',
|
|
845
|
+
hasAttention: true,
|
|
846
|
+
latestFeedbackAt: '2026-01-15T10:00:00Z'
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Item has newer feedback (re-review or additional comments)
|
|
850
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: true, _latest_feedback_at: '2026-01-16T14:30:00Z' };
|
|
851
|
+
assert.strictEqual(
|
|
852
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
853
|
+
true,
|
|
854
|
+
'Should reprocess when there is newer feedback (re-review)'
|
|
855
|
+
);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('shouldReprocess returns false when attention stays true with no timestamps', async () => {
|
|
859
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
860
|
+
|
|
861
|
+
const poller = createPoller({ stateFile });
|
|
862
|
+
// Legacy item processed with attention but no timestamp
|
|
821
863
|
poller.markProcessed('pr-1', {
|
|
822
864
|
source: 'my-prs-attention',
|
|
823
865
|
itemState: 'open',
|
|
824
866
|
hasAttention: true
|
|
825
867
|
});
|
|
826
868
|
|
|
827
|
-
// Item still has attention
|
|
869
|
+
// Item still has attention (no timestamp available either)
|
|
828
870
|
const item = { id: 'pr-1', state: 'open', _has_attention: true };
|
|
829
871
|
assert.strictEqual(
|
|
830
872
|
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
831
873
|
false,
|
|
832
|
-
'Should NOT reprocess when
|
|
874
|
+
'Should NOT reprocess when no timestamps available (legacy)'
|
|
833
875
|
);
|
|
834
876
|
});
|
|
835
877
|
|
|
@@ -1388,6 +1430,43 @@ describe('poller.js', () => {
|
|
|
1388
1430
|
assert.strictEqual(result[0]._attention_label, 'PR');
|
|
1389
1431
|
assert.strictEqual(result[0]._has_attention, false);
|
|
1390
1432
|
});
|
|
1433
|
+
|
|
1434
|
+
test('tracks latest feedback timestamp for detecting re-reviews', async () => {
|
|
1435
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
1436
|
+
|
|
1437
|
+
const items = [{
|
|
1438
|
+
number: 123,
|
|
1439
|
+
title: 'Test PR',
|
|
1440
|
+
user: { login: 'author' },
|
|
1441
|
+
_mergeable: 'MERGEABLE',
|
|
1442
|
+
_comments: [
|
|
1443
|
+
{ user: { login: 'reviewer1', type: 'User' }, body: 'Fix this', created_at: '2026-01-10T10:00:00Z', updated_at: '2026-01-10T10:00:00Z' },
|
|
1444
|
+
{ user: { login: 'reviewer2', type: 'User' }, state: 'CHANGES_REQUESTED', body: 'Also this', submitted_at: '2026-01-15T14:30:00Z' },
|
|
1445
|
+
]
|
|
1446
|
+
}];
|
|
1447
|
+
|
|
1448
|
+
const result = computeAttentionLabels(items, {});
|
|
1449
|
+
|
|
1450
|
+
assert.strictEqual(result[0]._has_attention, true);
|
|
1451
|
+
assert.strictEqual(result[0]._latest_feedback_at, '2026-01-15T14:30:00Z');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
test('sets latest feedback timestamp to null when no feedback', async () => {
|
|
1455
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
1456
|
+
|
|
1457
|
+
const items = [{
|
|
1458
|
+
number: 123,
|
|
1459
|
+
title: 'Test PR',
|
|
1460
|
+
user: { login: 'author' },
|
|
1461
|
+
_mergeable: 'MERGEABLE',
|
|
1462
|
+
_comments: []
|
|
1463
|
+
}];
|
|
1464
|
+
|
|
1465
|
+
const result = computeAttentionLabels(items, {});
|
|
1466
|
+
|
|
1467
|
+
assert.strictEqual(result[0]._has_attention, false);
|
|
1468
|
+
assert.strictEqual(result[0]._latest_feedback_at, null);
|
|
1469
|
+
});
|
|
1391
1470
|
});
|
|
1392
1471
|
|
|
1393
1472
|
describe('enrichItemsWithComments', () => {
|
package/test/unit/utils.test.js
CHANGED
|
@@ -192,6 +192,71 @@ describe('utils.js', () => {
|
|
|
192
192
|
});
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
describe('getLatestFeedbackTimestamp', () => {
|
|
196
|
+
test('returns null for empty/null/undefined comments', async () => {
|
|
197
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
198
|
+
|
|
199
|
+
assert.strictEqual(getLatestFeedbackTimestamp([], 'author'), null);
|
|
200
|
+
assert.strictEqual(getLatestFeedbackTimestamp(null, 'author'), null);
|
|
201
|
+
assert.strictEqual(getLatestFeedbackTimestamp(undefined, 'author'), null);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns latest timestamp from actionable feedback', async () => {
|
|
205
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
206
|
+
|
|
207
|
+
const comments = [
|
|
208
|
+
{ user: { login: 'reviewer1', type: 'User' }, body: 'Fix this', created_at: '2026-01-10T10:00:00Z', updated_at: '2026-01-10T10:00:00Z' },
|
|
209
|
+
{ user: { login: 'reviewer2', type: 'User' }, body: 'Also this', created_at: '2026-01-15T14:30:00Z', updated_at: '2026-01-15T14:30:00Z' },
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
assert.strictEqual(getLatestFeedbackTimestamp(comments, 'author'), '2026-01-15T14:30:00Z');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('uses submitted_at for PR reviews', async () => {
|
|
216
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
217
|
+
|
|
218
|
+
const comments = [
|
|
219
|
+
{ user: { login: 'reviewer', type: 'User' }, state: 'CHANGES_REQUESTED', body: 'Fix this', submitted_at: '2026-01-20T09:00:00Z', created_at: '2026-01-15T10:00:00Z' },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
assert.strictEqual(getLatestFeedbackTimestamp(comments, 'author'), '2026-01-20T09:00:00Z');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('ignores bot comments when computing latest timestamp', async () => {
|
|
226
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
227
|
+
|
|
228
|
+
const comments = [
|
|
229
|
+
{ user: { login: 'reviewer', type: 'User' }, body: 'Fix this', created_at: '2026-01-10T10:00:00Z', updated_at: '2026-01-10T10:00:00Z' },
|
|
230
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed', created_at: '2026-01-20T10:00:00Z', updated_at: '2026-01-20T10:00:00Z' },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
assert.strictEqual(getLatestFeedbackTimestamp(comments, 'author'), '2026-01-10T10:00:00Z');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('ignores approval-only reviews', async () => {
|
|
237
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
238
|
+
|
|
239
|
+
const comments = [
|
|
240
|
+
{ user: { login: 'reviewer', type: 'User' }, body: 'Fix this', created_at: '2026-01-10T10:00:00Z', updated_at: '2026-01-10T10:00:00Z' },
|
|
241
|
+
{ user: { login: 'approver', type: 'User' }, state: 'APPROVED', body: '', submitted_at: '2026-01-20T10:00:00Z' },
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
// Approval-only should be filtered out, latest is the first comment
|
|
245
|
+
assert.strictEqual(getLatestFeedbackTimestamp(comments, 'author'), '2026-01-10T10:00:00Z');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('returns null when only bot comments exist', async () => {
|
|
249
|
+
const { getLatestFeedbackTimestamp } = await import('../../service/utils.js');
|
|
250
|
+
|
|
251
|
+
const comments = [
|
|
252
|
+
{ user: { login: 'linear', type: 'User' }, body: 'ENG-123', created_at: '2026-01-10T10:00:00Z' },
|
|
253
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed', created_at: '2026-01-15T10:00:00Z' },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
assert.strictEqual(getLatestFeedbackTimestamp(comments, 'author'), null);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
195
260
|
describe('isApprovalOnly', () => {
|
|
196
261
|
test('returns true for APPROVED state with no body', async () => {
|
|
197
262
|
const { isApprovalOnly } = await import('../../service/utils.js');
|