opencode-pilot 0.9.3 → 0.11.0

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/README.md CHANGED
@@ -45,6 +45,7 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
45
45
 
46
46
  ### Key Sections
47
47
 
48
+ - **`server_port`** - Preferred OpenCode server port (e.g., `4096`). When multiple OpenCode instances are running, pilot attaches sessions to this port.
48
49
  - **`defaults`** - Default values applied to all sources
49
50
  - **`sources`** - What to poll (presets, shorthand, or full config)
50
51
  - **`tools`** - Field mappings to normalize different MCP APIs
@@ -93,6 +94,18 @@ opencode-pilot test-mapping MCP # Test field mappings
93
94
 
94
95
  ## Known Issues
95
96
 
97
+ ### Sessions attached to global server run in wrong directory
98
+
99
+ When using `server_port` to attach sessions to a global OpenCode server (e.g., OpenCode Desktop with worktree="/"), sessions are created in the server's working directory (typically home) instead of the project directory. This means:
100
+
101
+ - File tools resolve paths relative to home, not the project
102
+ - The agent sees the wrong `Working directory` in system prompt
103
+ - Git operations may target the wrong repository
104
+
105
+ **Workaround**: Don't set `server_port` in your config. Sessions will run in the correct directory but won't appear in OpenCode Desktop.
106
+
107
+ **Upstream issue**: [anomalyco/opencode#7376](https://github.com/anomalyco/opencode/issues/7376)
108
+
96
109
  ### Working directory doesn't switch when templates create worktrees/devcontainers
97
110
 
98
111
  When a template instructs the agent to create a git worktree or switch to a devcontainer, OpenCode's internal working directory context (`Instance.directory`) doesn't update. This means:
@@ -1,6 +1,11 @@
1
1
  # Example config.yaml for opencode-pilot
2
2
  # Copy to ~/.config/opencode-pilot/config.yaml
3
3
 
4
+ # Preferred OpenCode server port for attaching sessions
5
+ # When multiple OpenCode instances are running, pilot will attach new sessions
6
+ # to this port. If not set, pilot discovers servers automatically.
7
+ # server_port: 4096
8
+
4
9
  defaults:
5
10
  agent: plan
6
11
  prompt: default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -9,6 +9,7 @@ import { spawn, execSync } from "child_process";
9
9
  import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
12
+ import { getServerPort } from "./repo-config.js";
12
13
  import path from "path";
13
14
  import os from "os";
14
15
 
@@ -99,10 +100,11 @@ function isServerHealthy(project) {
99
100
  * Discover a running opencode server that matches the target directory
100
101
  *
101
102
  * Queries all running opencode servers and finds the best match based on:
102
- * 1. Exact sandbox match (highest priority)
103
- * 2. Exact worktree match
104
- * 3. Target is subdirectory of worktree
105
- * 4. Global server (worktree="/") as fallback
103
+ * 1. Configured server_port (highest priority if set and healthy)
104
+ * 2. Exact sandbox match
105
+ * 3. Exact worktree match
106
+ * 4. Target is subdirectory of worktree
107
+ * 5. Global server (worktree="/") as fallback
106
108
  *
107
109
  * Global servers are used as a fallback when no project-specific match is found,
108
110
  * since OpenCode Desktop may be connected to a global server that can display
@@ -112,11 +114,13 @@ function isServerHealthy(project) {
112
114
  * @param {object} [options] - Options for testing/mocking
113
115
  * @param {function} [options.getPorts] - Function to get server ports
114
116
  * @param {function} [options.fetch] - Function to fetch URLs
117
+ * @param {number} [options.preferredPort] - Preferred port to use (overrides config)
115
118
  * @returns {Promise<string|null>} Server URL (e.g., "http://localhost:4096") or null
116
119
  */
117
120
  export async function discoverOpencodeServer(targetDir, options = {}) {
118
121
  const getPorts = options.getPorts || getOpencodePorts;
119
122
  const fetchFn = options.fetch || fetch;
123
+ const preferredPort = options.preferredPort ?? getServerPort();
120
124
 
121
125
  const ports = await getPorts();
122
126
  if (ports.length === 0) {
@@ -124,7 +128,24 @@ export async function discoverOpencodeServer(targetDir, options = {}) {
124
128
  return null;
125
129
  }
126
130
 
127
- debug(`discoverOpencodeServer: checking ${ports.length} servers for ${targetDir}`);
131
+ debug(`discoverOpencodeServer: checking ${ports.length} servers for ${targetDir}, preferredPort=${preferredPort}`);
132
+
133
+ // If preferred port is configured and running, check it first
134
+ if (preferredPort && ports.includes(preferredPort)) {
135
+ const url = `http://localhost:${preferredPort}`;
136
+ try {
137
+ const response = await fetchFn(`${url}/project/current`);
138
+ if (response.ok) {
139
+ const project = await response.json();
140
+ if (isServerHealthy(project)) {
141
+ debug(`discoverOpencodeServer: using preferred port ${preferredPort}`);
142
+ return url;
143
+ }
144
+ }
145
+ } catch (err) {
146
+ debug(`discoverOpencodeServer: preferred port ${preferredPort} error: ${err.message}`);
147
+ }
148
+ }
128
149
 
129
150
  let bestMatch = null;
130
151
  let bestScore = 0;
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays } from "./repo-config.js";
13
- import { createPoller, pollGenericSource } from "./poller.js";
13
+ import { createPoller, pollGenericSource, enrichItemsWithComments } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
16
16
  import { debug } from "./logger.js";
@@ -126,6 +126,12 @@ export async function pollOnce(options = {}) {
126
126
  toolProviderConfig = getToolProviderConfig(source.tool.mcp);
127
127
  items = await pollGenericSource(source, { toolProviderConfig });
128
128
  debug(`Fetched ${items.length} items from ${sourceName}`);
129
+
130
+ // Enrich items with comments for bot filtering if configured
131
+ if (source.filter_bot_comments) {
132
+ items = await enrichItemsWithComments(items, source);
133
+ debug(`Enriched ${items.length} items with comments for bot filtering`);
134
+ }
129
135
  } catch (err) {
130
136
  console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
131
137
  continue;
package/service/poller.js CHANGED
@@ -273,6 +273,120 @@ export async function pollGenericSource(source, options = {}) {
273
273
  }
274
274
  }
275
275
 
276
+ /**
277
+ * Fetch comments for a GitHub issue/PR and enrich the item
278
+ *
279
+ * Uses the github_get_pull_request_comments tool to fetch review comments,
280
+ * or falls back to direct API call for issue comments.
281
+ *
282
+ * @param {object} item - Item with owner, repo_short, and number fields
283
+ * @param {object} [options] - Options
284
+ * @param {number} [options.timeout] - Timeout in ms (default: 30000)
285
+ * @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
286
+ * @returns {Promise<Array>} Array of comment objects
287
+ */
288
+ export async function fetchGitHubComments(item, options = {}) {
289
+ const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
290
+
291
+ // Extract owner and repo from item
292
+ // The item should have repository_full_name (e.g., "owner/repo") from mapping
293
+ const fullName = item.repository_full_name;
294
+ if (!fullName) {
295
+ console.error("[poller] Cannot fetch comments: missing repository_full_name");
296
+ return [];
297
+ }
298
+
299
+ const [owner, repo] = fullName.split("/");
300
+ const number = item.number;
301
+
302
+ if (!owner || !repo || !number) {
303
+ console.error("[poller] Cannot fetch comments: missing owner, repo, or number");
304
+ return [];
305
+ }
306
+
307
+ let mcpConfig;
308
+ try {
309
+ mcpConfig = getMcpConfig("github", options.opencodeConfigPath);
310
+ } catch {
311
+ console.error("[poller] GitHub MCP not configured, cannot fetch comments");
312
+ return [];
313
+ }
314
+
315
+ const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
316
+
317
+ try {
318
+ const transport = await createTransport(mcpConfig);
319
+
320
+ await Promise.race([
321
+ client.connect(transport),
322
+ createTimeout(timeout, "MCP connection for comments"),
323
+ ]);
324
+
325
+ // Use github_get_pull_request_comments for PR review comments
326
+ const result = await Promise.race([
327
+ client.callTool({
328
+ name: "github_get_pull_request_comments",
329
+ arguments: { owner, repo, pull_number: number }
330
+ }),
331
+ createTimeout(timeout, "fetch comments"),
332
+ ]);
333
+
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 [];
342
+ }
343
+ } catch (err) {
344
+ console.error(`[poller] Error fetching comments: ${err.message}`);
345
+ return [];
346
+ } finally {
347
+ try {
348
+ await Promise.race([
349
+ client.close(),
350
+ new Promise(resolve => setTimeout(resolve, 3000)),
351
+ ]);
352
+ } catch {
353
+ // Ignore close errors
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Enrich items with comments for bot filtering
360
+ *
361
+ * For items from sources with filter_bot_comments: true, fetches comments
362
+ * and attaches them as _comments field for readiness evaluation.
363
+ *
364
+ * @param {Array} items - Items to enrich
365
+ * @param {object} source - Source configuration with optional filter_bot_comments
366
+ * @param {object} [options] - Options passed to fetchGitHubComments
367
+ * @returns {Promise<Array>} Items with _comments field added
368
+ */
369
+ export async function enrichItemsWithComments(items, source, options = {}) {
370
+ // Skip if not configured or not a GitHub source
371
+ if (!source.filter_bot_comments || source.tool?.mcp !== "github") {
372
+ return items;
373
+ }
374
+
375
+ // Fetch comments for each item (could be parallelized with Promise.all for speed)
376
+ const enrichedItems = [];
377
+ for (const item of items) {
378
+ // Only fetch if item has comments
379
+ if (item.comments > 0) {
380
+ const comments = await fetchGitHubComments(item, options);
381
+ enrichedItems.push({ ...item, _comments: comments });
382
+ } else {
383
+ enrichedItems.push(item);
384
+ }
385
+ }
386
+
387
+ return enrichedItems;
388
+ }
389
+
276
390
  /**
277
391
  * Create a poller instance with state tracking
278
392
  *
@@ -57,3 +57,6 @@ my-prs-feedback:
57
57
  reprocess_on:
58
58
  - state
59
59
  - updated_at
60
+ # Filter out PRs where all comments are from bots or the PR author
61
+ # Fetches comments via API and enriches items with _comments for readiness check
62
+ filter_bot_comments: true
@@ -4,9 +4,12 @@
4
4
  * Evaluates whether an issue is ready to be worked on based on:
5
5
  * - Label constraints (blocking labels, required labels)
6
6
  * - Dependencies (blocked by references in body)
7
+ * - Bot comment filtering (for PR feedback sources)
7
8
  * - Priority scoring (label weights, age bonus)
8
9
  */
9
10
 
11
+ import { hasNonBotFeedback } from "./utils.js";
12
+
10
13
  /**
11
14
  * Dependency reference patterns in issue body
12
15
  */
@@ -131,6 +134,45 @@ export function checkDependencies(issue, config) {
131
134
  return { ready: true };
132
135
  }
133
136
 
137
+ /**
138
+ * Check if a PR/issue has meaningful (non-bot, non-author) comments
139
+ *
140
+ * This check is only applied when the item has been enriched with `_comments`
141
+ * (an array of comment objects with user.login and user.type fields).
142
+ * Items without `_comments` are considered ready (check is skipped).
143
+ *
144
+ * Used to filter PRs from feedback sources where bot comments (CI, coverage, etc.)
145
+ * should not trigger the author to take action.
146
+ *
147
+ * @param {object} item - Item with optional _comments array and user.login
148
+ * @param {object} config - Repo config (currently unused but kept for API consistency)
149
+ * @returns {object} { ready: boolean, reason?: string }
150
+ */
151
+ export function checkBotComments(item, config) {
152
+ // Skip check if no _comments field (item not enriched)
153
+ if (!item._comments) {
154
+ return { ready: true };
155
+ }
156
+
157
+ // Empty comments array means no comments - consider ready (no feedback)
158
+ if (item._comments.length === 0) {
159
+ return { ready: true };
160
+ }
161
+
162
+ // Get author username
163
+ const authorUsername = item.user?.login;
164
+
165
+ // Check if there's non-bot, non-author feedback
166
+ if (hasNonBotFeedback(item._comments, authorUsername)) {
167
+ return { ready: true };
168
+ }
169
+
170
+ return {
171
+ ready: false,
172
+ reason: "Only bot or author comments - no human feedback requiring action",
173
+ };
174
+ }
175
+
134
176
  /**
135
177
  * Calculate priority score for an issue
136
178
  * @param {object} issue - Issue with labels and created_at
@@ -194,6 +236,16 @@ export function evaluateReadiness(issue, config) {
194
236
  };
195
237
  }
196
238
 
239
+ // Check bot comments (for PRs enriched with _comments)
240
+ const botResult = checkBotComments(issue, config);
241
+ if (!botResult.ready) {
242
+ return {
243
+ ready: false,
244
+ reason: botResult.reason,
245
+ priority: 0,
246
+ };
247
+ }
248
+
197
249
  // Calculate priority for ready issues
198
250
  const priority = calculatePriority(issue, config);
199
251
 
@@ -310,6 +310,15 @@ export function getCleanupTtlDays() {
310
310
  return config?.cleanup?.ttl_days ?? 30;
311
311
  }
312
312
 
313
+ /**
314
+ * Get preferred OpenCode server port from config
315
+ * @returns {number|null} Port number or null if not configured
316
+ */
317
+ export function getServerPort() {
318
+ const config = getRawConfig();
319
+ return config?.server_port ?? null;
320
+ }
321
+
313
322
  /**
314
323
  * Clear config cache (for testing)
315
324
  */
package/service/utils.js CHANGED
@@ -19,3 +19,65 @@ export function getNestedValue(obj, path) {
19
19
  }
20
20
  return value;
21
21
  }
22
+
23
+ /**
24
+ * Check if a username represents a bot account
25
+ *
26
+ * Detects bots by:
27
+ * 1. Username suffix: [bot] (e.g., "github-actions[bot]", "dependabot[bot]")
28
+ * 2. User type field: "Bot" (GitHub API provides this)
29
+ *
30
+ * @param {string} username - GitHub username to check
31
+ * @param {string} [type] - User type from API (e.g., "Bot", "User")
32
+ * @returns {boolean} True if the user is a bot
33
+ */
34
+ export function isBot(username, type) {
35
+ // Handle null/undefined/empty
36
+ if (!username) return false;
37
+
38
+ // Check user type field (GitHub API provides "Bot" type)
39
+ if (type && type.toLowerCase() === "bot") return true;
40
+
41
+ // Check for [bot] suffix in username
42
+ if (username.toLowerCase().endsWith("[bot]")) return true;
43
+
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Check if a PR/issue has non-bot feedback (comments from humans other than the author)
49
+ *
50
+ * Used to filter out PRs where only bots have commented, since those don't
51
+ * require the author's attention for human feedback.
52
+ *
53
+ * @param {Array} comments - Array of comment objects with user.login and user.type
54
+ * @param {string} authorUsername - Username of the PR/issue author
55
+ * @returns {boolean} True if there's at least one non-bot, non-author comment
56
+ */
57
+ export function hasNonBotFeedback(comments, authorUsername) {
58
+ // Handle null/undefined/empty
59
+ if (!comments || !Array.isArray(comments) || comments.length === 0) {
60
+ return false;
61
+ }
62
+
63
+ const authorLower = authorUsername?.toLowerCase();
64
+
65
+ for (const comment of comments) {
66
+ const user = comment.user;
67
+ if (!user) continue;
68
+
69
+ const username = user.login;
70
+ const userType = user.type;
71
+
72
+ // Skip if it's a bot
73
+ if (isBot(username, userType)) continue;
74
+
75
+ // Skip if it's the author themselves
76
+ if (authorLower && username?.toLowerCase() === authorLower) continue;
77
+
78
+ // Found a non-bot, non-author comment
79
+ return true;
80
+ }
81
+
82
+ return false;
83
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Tests for readiness.js - Issue/PR readiness evaluation
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { mkdtempSync, rmSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+
11
+ describe('readiness.js', () => {
12
+ let tempDir;
13
+
14
+ beforeEach(() => {
15
+ tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-readiness-test-'));
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('checkLabels', () => {
23
+ test('returns ready when no label constraints', async () => {
24
+ const { checkLabels } = await import('../../service/readiness.js');
25
+
26
+ const issue = { labels: ['bug', 'help wanted'] };
27
+ const config = {};
28
+
29
+ const result = checkLabels(issue, config);
30
+
31
+ assert.strictEqual(result.ready, true);
32
+ });
33
+
34
+ test('returns not ready when has blocking label', async () => {
35
+ const { checkLabels } = await import('../../service/readiness.js');
36
+
37
+ const issue = { labels: ['bug', 'wip'] };
38
+ const config = {
39
+ readiness: {
40
+ labels: {
41
+ exclude: ['wip', 'do-not-merge']
42
+ }
43
+ }
44
+ };
45
+
46
+ const result = checkLabels(issue, config);
47
+
48
+ assert.strictEqual(result.ready, false);
49
+ assert.ok(result.reason.includes('wip'));
50
+ });
51
+
52
+ test('returns not ready when missing required label', async () => {
53
+ const { checkLabels } = await import('../../service/readiness.js');
54
+
55
+ const issue = { labels: ['bug'] };
56
+ const config = {
57
+ readiness: {
58
+ labels: {
59
+ required: ['approved', 'ready']
60
+ }
61
+ }
62
+ };
63
+
64
+ const result = checkLabels(issue, config);
65
+
66
+ assert.strictEqual(result.ready, false);
67
+ assert.ok(result.reason.includes('approved'));
68
+ });
69
+
70
+ test('handles label objects with name property', async () => {
71
+ const { checkLabels } = await import('../../service/readiness.js');
72
+
73
+ const issue = {
74
+ labels: [
75
+ { name: 'bug' },
76
+ { name: 'approved' }
77
+ ]
78
+ };
79
+ const config = {
80
+ readiness: {
81
+ labels: {
82
+ required: ['approved']
83
+ }
84
+ }
85
+ };
86
+
87
+ const result = checkLabels(issue, config);
88
+
89
+ assert.strictEqual(result.ready, true);
90
+ });
91
+ });
92
+
93
+ describe('checkBotComments', () => {
94
+ test('returns ready when there are non-bot comments', async () => {
95
+ const { checkBotComments } = await import('../../service/readiness.js');
96
+
97
+ const pr = {
98
+ user: { login: 'author' },
99
+ _comments: [
100
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
101
+ { user: { login: 'reviewer', type: 'User' }, body: 'Please fix the bug' },
102
+ ]
103
+ };
104
+ const config = {};
105
+
106
+ const result = checkBotComments(pr, config);
107
+
108
+ assert.strictEqual(result.ready, true);
109
+ });
110
+
111
+ test('returns not ready when all comments are from bots', async () => {
112
+ const { checkBotComments } = await import('../../service/readiness.js');
113
+
114
+ const pr = {
115
+ user: { login: 'author' },
116
+ _comments: [
117
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
118
+ { user: { login: 'codecov[bot]', type: 'Bot' }, body: 'Coverage report' },
119
+ ]
120
+ };
121
+ const config = {};
122
+
123
+ const result = checkBotComments(pr, config);
124
+
125
+ assert.strictEqual(result.ready, false);
126
+ assert.ok(result.reason.includes('bot'));
127
+ });
128
+
129
+ test('returns not ready when only author and bots have commented', async () => {
130
+ const { checkBotComments } = await import('../../service/readiness.js');
131
+
132
+ const pr = {
133
+ user: { login: 'athal7' },
134
+ _comments: [
135
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
136
+ { user: { login: 'athal7', type: 'User' }, body: 'Added screenshots' },
137
+ ]
138
+ };
139
+ const config = {};
140
+
141
+ const result = checkBotComments(pr, config);
142
+
143
+ assert.strictEqual(result.ready, false);
144
+ });
145
+
146
+ test('returns ready when no _comments field (skip check)', async () => {
147
+ const { checkBotComments } = await import('../../service/readiness.js');
148
+
149
+ const pr = {
150
+ user: { login: 'author' },
151
+ comments: 5 // count only, no _comments enrichment
152
+ };
153
+ const config = {};
154
+
155
+ const result = checkBotComments(pr, config);
156
+
157
+ assert.strictEqual(result.ready, true);
158
+ });
159
+
160
+ test('returns ready when _comments is empty (no comments yet)', async () => {
161
+ const { checkBotComments } = await import('../../service/readiness.js');
162
+
163
+ const pr = {
164
+ user: { login: 'author' },
165
+ _comments: []
166
+ };
167
+ const config = {};
168
+
169
+ const result = checkBotComments(pr, config);
170
+
171
+ assert.strictEqual(result.ready, true);
172
+ });
173
+ });
174
+
175
+ describe('evaluateReadiness', () => {
176
+ test('checks bot comments when _comments is present', async () => {
177
+ const { evaluateReadiness } = await import('../../service/readiness.js');
178
+
179
+ const pr = {
180
+ user: { login: 'author' },
181
+ _comments: [
182
+ { user: { login: 'dependabot[bot]', type: 'Bot' }, body: 'Bump dependency' },
183
+ ]
184
+ };
185
+ const config = {};
186
+
187
+ const result = evaluateReadiness(pr, config);
188
+
189
+ assert.strictEqual(result.ready, false);
190
+ assert.ok(result.reason.includes('bot'));
191
+ });
192
+
193
+ test('passes when there is real human feedback', async () => {
194
+ const { evaluateReadiness } = await import('../../service/readiness.js');
195
+
196
+ const pr = {
197
+ user: { login: 'author' },
198
+ _comments: [
199
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
200
+ { user: { login: 'teammate', type: 'User' }, body: 'LGTM!' },
201
+ ]
202
+ };
203
+ const config = {};
204
+
205
+ const result = evaluateReadiness(pr, config);
206
+
207
+ assert.strictEqual(result.ready, true);
208
+ });
209
+ });
210
+ });
@@ -6,6 +6,102 @@ import { test, describe } from 'node:test';
6
6
  import assert from 'node:assert';
7
7
 
8
8
  describe('utils.js', () => {
9
+ describe('isBot', () => {
10
+ test('detects GitHub bot usernames with [bot] suffix', async () => {
11
+ const { isBot } = await import('../../service/utils.js');
12
+
13
+ assert.strictEqual(isBot('github-actions[bot]'), true);
14
+ assert.strictEqual(isBot('dependabot[bot]'), true);
15
+ assert.strictEqual(isBot('renovate[bot]'), true);
16
+ assert.strictEqual(isBot('codecov[bot]'), true);
17
+ });
18
+
19
+ test('detects bots by type field', async () => {
20
+ const { isBot } = await import('../../service/utils.js');
21
+
22
+ // When user object has type: "Bot"
23
+ assert.strictEqual(isBot('some-user', 'Bot'), true);
24
+ assert.strictEqual(isBot('another-user', 'bot'), true);
25
+ });
26
+
27
+ test('returns false for regular users', async () => {
28
+ const { isBot } = await import('../../service/utils.js');
29
+
30
+ assert.strictEqual(isBot('athal7'), false);
31
+ assert.strictEqual(isBot('octocat'), false);
32
+ assert.strictEqual(isBot('some-developer'), false);
33
+ });
34
+
35
+ test('returns false for regular users with type User', async () => {
36
+ const { isBot } = await import('../../service/utils.js');
37
+
38
+ assert.strictEqual(isBot('athal7', 'User'), false);
39
+ assert.strictEqual(isBot('octocat', 'user'), false);
40
+ });
41
+
42
+ test('handles edge cases', async () => {
43
+ const { isBot } = await import('../../service/utils.js');
44
+
45
+ assert.strictEqual(isBot(''), false);
46
+ assert.strictEqual(isBot(null), false);
47
+ assert.strictEqual(isBot(undefined), false);
48
+ });
49
+ });
50
+
51
+ describe('hasNonBotFeedback', () => {
52
+ test('returns true when there are non-bot, non-author comments', async () => {
53
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
54
+
55
+ const comments = [
56
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
57
+ { user: { login: 'reviewer', type: 'User' }, body: 'Please fix the bug' },
58
+ ];
59
+
60
+ assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), true);
61
+ });
62
+
63
+ test('returns false when all comments are from bots', async () => {
64
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
65
+
66
+ const comments = [
67
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
68
+ { user: { login: 'codecov[bot]', type: 'Bot' }, body: 'Coverage report' },
69
+ ];
70
+
71
+ assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
72
+ });
73
+
74
+ test('returns false when only author has commented', async () => {
75
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
76
+
77
+ const comments = [
78
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
79
+ { user: { login: 'athal7', type: 'User' }, body: 'Added screenshots' },
80
+ ];
81
+
82
+ assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
83
+ });
84
+
85
+ test('returns false for empty comments array', async () => {
86
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
87
+
88
+ assert.strictEqual(hasNonBotFeedback([], 'athal7'), false);
89
+ assert.strictEqual(hasNonBotFeedback(null, 'athal7'), false);
90
+ assert.strictEqual(hasNonBotFeedback(undefined, 'athal7'), false);
91
+ });
92
+
93
+ test('handles nested user object with login and type', async () => {
94
+ const { hasNonBotFeedback } = await import('../../service/utils.js');
95
+
96
+ const comments = [
97
+ { user: { login: 'dependabot[bot]', type: 'Bot' }, body: 'Bump lodash' },
98
+ { user: { login: 'maintainer', type: 'User' }, body: 'LGTM' },
99
+ ];
100
+
101
+ assert.strictEqual(hasNonBotFeedback(comments, 'contributor'), true);
102
+ });
103
+ });
104
+
9
105
  describe('getNestedValue', () => {
10
106
  test('gets top-level value', async () => {
11
107
  const { getNestedValue } = await import('../../service/utils.js');