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.
@@ -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.2.tar.gz"
5
- sha256 "ffdb6ca6cf6b4d0656df859c91fd287c8a466137e86accfcb2b6d1a056782562"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.3",
3
+ "version": "0.24.5",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -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
- // Use AbortController with timeout (same pattern as createSessionViaApi)
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
- const timeoutId = setTimeout(() => controller.abort(), 10000);
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: sent ${parsedCommand ? 'command' : 'message'} to session ${sessionId}`);
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
- debug(`sendMessageToSession: request started for session ${sessionId} (response aborted as expected)`);
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
- // Use AbortController with timeout for the request
710
- // The endpoints return a chunked/streaming response that stays open until
711
- // the agent completes. We only need to verify the request was accepted.
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
- const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
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: sent ${parsedCommand ? 'command' : 'message'} to session ${session.id}`);
794
+ debug(`createSessionViaApi: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${session.id}`);
766
795
  } catch (abortErr) {
767
796
  clearTimeout(timeoutId);
768
- // AbortError is expected - we intentionally abort after verifying the request started
769
- // The server accepted our request, we just don't need to wait for the response
770
- if (abortErr.name === 'AbortError') {
771
- debug(`createSessionViaApi: request started for session ${session.id} (response aborted as expected)`);
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
 
@@ -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
- // Only reprocess when attention changes from false to true
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
- // Only trigger if we have stored state (not legacy items) and attention changed false -> true
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
- const user = comment.user;
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');
@@ -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 attention stays true'
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', () => {
@@ -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');