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.
- package/Formula/opencode-pilot.rb +2 -2
- package/package.json +1 -1
- package/service/poll-service.js +3 -0
- package/service/poller.js +20 -3
- package/service/utils.js +82 -28
- 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/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
|
+
}
|
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');
|