opencode-pilot 0.24.4 → 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.3.tar.gz"
5
- sha256 "39b5b418b5af1c3731de56f549303b0af55591b5730522802d95267724889f6f"
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.4",
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",
@@ -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
+ }
@@ -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');