opencode-pilot 0.11.0 → 0.11.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
package/service/poller.js CHANGED
@@ -273,17 +273,53 @@ export async function pollGenericSource(source, options = {}) {
273
273
  }
274
274
  }
275
275
 
276
+ /**
277
+ * Fetch issue comments using gh CLI
278
+ *
279
+ * The GitHub MCP server doesn't have a tool to list issue comments,
280
+ * so we use gh CLI directly. This fetches the conversation thread
281
+ * where bots like Linear post their comments.
282
+ *
283
+ * @param {string} owner - Repository owner
284
+ * @param {string} repo - Repository name
285
+ * @param {number} number - Issue/PR number
286
+ * @param {number} timeout - Timeout in ms
287
+ * @returns {Promise<Array>} Array of comment objects
288
+ */
289
+ async function fetchIssueCommentsViaCli(owner, repo, number, timeout) {
290
+ const { exec } = await import('child_process');
291
+ const { promisify } = await import('util');
292
+ const execAsync = promisify(exec);
293
+
294
+ try {
295
+ const { stdout } = await Promise.race([
296
+ execAsync(`gh api repos/${owner}/${repo}/issues/${number}/comments`),
297
+ createTimeout(timeout, "gh api call"),
298
+ ]);
299
+
300
+ const comments = JSON.parse(stdout);
301
+ return Array.isArray(comments) ? comments : [];
302
+ } catch (err) {
303
+ // gh CLI might not be available or authenticated
304
+ console.error(`[poller] Error fetching issue comments via gh: ${err.message}`);
305
+ return [];
306
+ }
307
+ }
308
+
276
309
  /**
277
310
  * Fetch comments for a GitHub issue/PR and enrich the item
278
311
  *
279
- * Uses the github_get_pull_request_comments tool to fetch review comments,
280
- * or falls back to direct API call for issue comments.
312
+ * Fetches BOTH types of comments:
313
+ * 1. PR review comments (inline code comments) via MCP github_get_pull_request_comments
314
+ * 2. Issue comments (conversation thread) via gh CLI
315
+ *
316
+ * This is necessary because bots like Linear post to issue comments, not PR review comments.
281
317
  *
282
318
  * @param {object} item - Item with owner, repo_short, and number fields
283
319
  * @param {object} [options] - Options
284
320
  * @param {number} [options.timeout] - Timeout in ms (default: 30000)
285
321
  * @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
286
- * @returns {Promise<Array>} Array of comment objects
322
+ * @returns {Promise<Array>} Array of comment objects (merged from both endpoints)
287
323
  */
288
324
  export async function fetchGitHubComments(item, options = {}) {
289
325
  const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
@@ -322,24 +358,31 @@ export async function fetchGitHubComments(item, options = {}) {
322
358
  createTimeout(timeout, "MCP connection for comments"),
323
359
  ]);
324
360
 
325
- // Use github_get_pull_request_comments for PR review comments
326
- const result = await Promise.race([
361
+ // Fetch both PR review comments (via MCP) AND issue comments (via gh CLI) in parallel
362
+ const [prCommentsResult, issueComments] = await Promise.all([
363
+ // PR review comments via MCP (may not be available on all MCP servers)
327
364
  client.callTool({
328
365
  name: "github_get_pull_request_comments",
329
366
  arguments: { owner, repo, pull_number: number }
330
- }),
331
- createTimeout(timeout, "fetch comments"),
367
+ }).catch(() => null), // Gracefully handle if tool doesn't exist
368
+ // Issue comments via gh CLI (conversation thread where Linear bot posts)
369
+ fetchIssueCommentsViaCli(owner, repo, number, timeout),
332
370
  ]);
333
371
 
334
- const text = result.content?.[0]?.text;
335
- if (!text) return [];
336
-
337
- try {
338
- const comments = JSON.parse(text);
339
- return Array.isArray(comments) ? comments : [];
340
- } catch {
341
- return [];
372
+ // Parse PR review comments
373
+ let prComments = [];
374
+ const prText = prCommentsResult?.content?.[0]?.text;
375
+ if (prText) {
376
+ try {
377
+ const parsed = JSON.parse(prText);
378
+ prComments = Array.isArray(parsed) ? parsed : [];
379
+ } catch {
380
+ // Ignore parse errors
381
+ }
342
382
  }
383
+
384
+ // Return merged comments from both sources
385
+ return [...prComments, ...issueComments];
343
386
  } catch (err) {
344
387
  console.error(`[poller] Error fetching comments: ${err.message}`);
345
388
  return [];
package/service/utils.js CHANGED
@@ -20,6 +20,26 @@ export function getNestedValue(obj, path) {
20
20
  return value;
21
21
  }
22
22
 
23
+ /**
24
+ * Check if a comment/review is an approval-only (no actionable feedback)
25
+ *
26
+ * PR reviews have a state field (APPROVED, CHANGES_REQUESTED, COMMENTED).
27
+ * An approval without substantive body text doesn't require action from the author.
28
+ *
29
+ * @param {object} comment - Comment or review object with optional state and body
30
+ * @returns {boolean} True if this is a pure approval with no actionable feedback
31
+ */
32
+ export function isApprovalOnly(comment) {
33
+ // Only applies to PR reviews with APPROVED state
34
+ if (comment.state !== 'APPROVED') return false;
35
+
36
+ // If there's substantive body text, it might contain feedback
37
+ const body = comment.body || '';
38
+ if (body.trim().length > 0) return false;
39
+
40
+ return true;
41
+ }
42
+
23
43
  /**
24
44
  * Check if a username represents a bot account
25
45
  *
@@ -50,9 +70,12 @@ export function isBot(username, type) {
50
70
  * Used to filter out PRs where only bots have commented, since those don't
51
71
  * require the author's attention for human feedback.
52
72
  *
73
+ * Also skips approval-only reviews (APPROVED state with no body text) since
74
+ * approvals don't require action from the author.
75
+ *
53
76
  * @param {Array} comments - Array of comment objects with user.login and user.type
54
77
  * @param {string} authorUsername - Username of the PR/issue author
55
- * @returns {boolean} True if there's at least one non-bot, non-author comment
78
+ * @returns {boolean} True if there's at least one non-bot, non-author, actionable comment
56
79
  */
57
80
  export function hasNonBotFeedback(comments, authorUsername) {
58
81
  // Handle null/undefined/empty
@@ -75,7 +98,10 @@ export function hasNonBotFeedback(comments, authorUsername) {
75
98
  // Skip if it's the author themselves
76
99
  if (authorLower && username?.toLowerCase() === authorLower) continue;
77
100
 
78
- // Found a non-bot, non-author comment
101
+ // Skip approval-only reviews (no actionable feedback)
102
+ if (isApprovalOnly(comment)) continue;
103
+
104
+ // Found a non-bot, non-author, actionable comment
79
105
  return true;
80
106
  }
81
107
 
@@ -734,6 +734,29 @@ describe('poller.js', () => {
734
734
  });
735
735
  });
736
736
 
737
+ describe('fetchGitHubComments', () => {
738
+ // Note: These tests document the expected behavior
739
+ // Actual MCP calls require mocking which is complex
740
+
741
+ test('should fetch both PR review comments AND issue comments', async () => {
742
+ // The issue: Linear bot posts to issue comments endpoint, not PR review comments
743
+ // PR review comments: GET /repos/{owner}/{repo}/pulls/{pull_number}/comments
744
+ // Issue comments: GET /repos/{owner}/{repo}/issues/{issue_number}/comments
745
+ //
746
+ // For proper bot filtering, we need to check BOTH endpoints
747
+ // Currently only PR review comments are fetched, causing Linear bot
748
+ // comments to be missed (they appear in issue comments)
749
+
750
+ // This test documents the expected behavior - fetchGitHubComments should
751
+ // return comments from both endpoints merged together
752
+ const { fetchGitHubComments } = await import('../../service/poller.js');
753
+
754
+ // Without proper mocking, we can only test the function exists
755
+ // and accepts the right parameters
756
+ assert.strictEqual(typeof fetchGitHubComments, 'function');
757
+ });
758
+ });
759
+
737
760
  describe('parseJsonArray', () => {
738
761
  test('parses direct array response', async () => {
739
762
  const { parseJsonArray } = await import('../../service/poller.js');
@@ -100,6 +100,77 @@ describe('utils.js', () => {
100
100
 
101
101
  assert.strictEqual(hasNonBotFeedback(comments, 'contributor'), true);
102
102
  });
103
+
104
+ test('returns false when only approval-only reviews (no feedback body)', async () => {
105
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
106
+
107
+ // PR reviews with APPROVED state but no body should not trigger feedback
108
+ const comments = [
109
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
110
+ { user: { login: 'reviewer', type: 'User' }, state: 'APPROVED', body: '' },
111
+ ];
112
+
113
+ assert.strictEqual(hasNonBotFeedback(comments, 'author'), false);
114
+ });
115
+
116
+ test('returns true when approval includes feedback body', async () => {
117
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
118
+
119
+ // If someone approves but leaves feedback, we should consider it actionable
120
+ const comments = [
121
+ { user: { login: 'reviewer', type: 'User' }, state: 'APPROVED', body: 'LGTM but consider adding a test for edge cases' },
122
+ ];
123
+
124
+ assert.strictEqual(hasNonBotFeedback(comments, 'author'), true);
125
+ });
126
+
127
+ test('returns true for CHANGES_REQUESTED reviews', async () => {
128
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
129
+
130
+ const comments = [
131
+ { user: { login: 'reviewer', type: 'User' }, state: 'CHANGES_REQUESTED', body: 'Please fix this' },
132
+ ];
133
+
134
+ assert.strictEqual(hasNonBotFeedback(comments, 'author'), true);
135
+ });
136
+ });
137
+
138
+ describe('isApprovalOnly', () => {
139
+ test('returns true for APPROVED state with no body', async () => {
140
+ const { isApprovalOnly } = await import('../../service/utils.js');
141
+
142
+ assert.strictEqual(isApprovalOnly({ state: 'APPROVED' }), true);
143
+ assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: '' }), true);
144
+ assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: null }), true);
145
+ });
146
+
147
+ test('returns false for APPROVED with substantive body', async () => {
148
+ const { isApprovalOnly } = await import('../../service/utils.js');
149
+
150
+ // If someone approves but leaves feedback, we should still consider it feedback
151
+ assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: 'LGTM but consider renaming this function' }), false);
152
+ });
153
+
154
+ test('returns false for CHANGES_REQUESTED', async () => {
155
+ const { isApprovalOnly } = await import('../../service/utils.js');
156
+
157
+ assert.strictEqual(isApprovalOnly({ state: 'CHANGES_REQUESTED', body: 'Please fix this' }), false);
158
+ assert.strictEqual(isApprovalOnly({ state: 'CHANGES_REQUESTED' }), false);
159
+ });
160
+
161
+ test('returns false for COMMENTED state', async () => {
162
+ const { isApprovalOnly } = await import('../../service/utils.js');
163
+
164
+ assert.strictEqual(isApprovalOnly({ state: 'COMMENTED', body: 'This looks good' }), false);
165
+ });
166
+
167
+ test('returns false for regular comments without state', async () => {
168
+ const { isApprovalOnly } = await import('../../service/utils.js');
169
+
170
+ // Issue comments don't have state field
171
+ assert.strictEqual(isApprovalOnly({ body: 'Please address this' }), false);
172
+ assert.strictEqual(isApprovalOnly({}), false);
173
+ });
103
174
  });
104
175
 
105
176
  describe('getNestedValue', () => {