opencode-pilot 0.10.0 → 0.11.1
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 +12 -0
- package/package.json +1 -1
- package/service/poll-service.js +7 -1
- package/service/poller.js +157 -0
- package/service/presets/github.yaml +3 -0
- package/service/readiness.js +52 -0
- package/service/utils.js +62 -0
- package/test/unit/poller.test.js +23 -0
- package/test/unit/readiness.test.js +210 -0
- package/test/unit/utils.test.js +96 -0
package/README.md
CHANGED
|
@@ -94,6 +94,18 @@ opencode-pilot test-mapping MCP # Test field mappings
|
|
|
94
94
|
|
|
95
95
|
## Known Issues
|
|
96
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
|
+
|
|
97
109
|
### Working directory doesn't switch when templates create worktrees/devcontainers
|
|
98
110
|
|
|
99
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:
|
package/package.json
CHANGED
package/service/poll-service.js
CHANGED
|
@@ -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,163 @@ 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
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fetch comments for a GitHub issue/PR and enrich the item
|
|
311
|
+
*
|
|
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.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} item - Item with owner, repo_short, and number fields
|
|
319
|
+
* @param {object} [options] - Options
|
|
320
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
321
|
+
* @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
|
|
322
|
+
* @returns {Promise<Array>} Array of comment objects (merged from both endpoints)
|
|
323
|
+
*/
|
|
324
|
+
export async function fetchGitHubComments(item, options = {}) {
|
|
325
|
+
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
326
|
+
|
|
327
|
+
// Extract owner and repo from item
|
|
328
|
+
// The item should have repository_full_name (e.g., "owner/repo") from mapping
|
|
329
|
+
const fullName = item.repository_full_name;
|
|
330
|
+
if (!fullName) {
|
|
331
|
+
console.error("[poller] Cannot fetch comments: missing repository_full_name");
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const [owner, repo] = fullName.split("/");
|
|
336
|
+
const number = item.number;
|
|
337
|
+
|
|
338
|
+
if (!owner || !repo || !number) {
|
|
339
|
+
console.error("[poller] Cannot fetch comments: missing owner, repo, or number");
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let mcpConfig;
|
|
344
|
+
try {
|
|
345
|
+
mcpConfig = getMcpConfig("github", options.opencodeConfigPath);
|
|
346
|
+
} catch {
|
|
347
|
+
console.error("[poller] GitHub MCP not configured, cannot fetch comments");
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const transport = await createTransport(mcpConfig);
|
|
355
|
+
|
|
356
|
+
await Promise.race([
|
|
357
|
+
client.connect(transport),
|
|
358
|
+
createTimeout(timeout, "MCP connection for comments"),
|
|
359
|
+
]);
|
|
360
|
+
|
|
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)
|
|
364
|
+
client.callTool({
|
|
365
|
+
name: "github_get_pull_request_comments",
|
|
366
|
+
arguments: { owner, repo, pull_number: number }
|
|
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),
|
|
370
|
+
]);
|
|
371
|
+
|
|
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
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Return merged comments from both sources
|
|
385
|
+
return [...prComments, ...issueComments];
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error(`[poller] Error fetching comments: ${err.message}`);
|
|
388
|
+
return [];
|
|
389
|
+
} finally {
|
|
390
|
+
try {
|
|
391
|
+
await Promise.race([
|
|
392
|
+
client.close(),
|
|
393
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
394
|
+
]);
|
|
395
|
+
} catch {
|
|
396
|
+
// Ignore close errors
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Enrich items with comments for bot filtering
|
|
403
|
+
*
|
|
404
|
+
* For items from sources with filter_bot_comments: true, fetches comments
|
|
405
|
+
* and attaches them as _comments field for readiness evaluation.
|
|
406
|
+
*
|
|
407
|
+
* @param {Array} items - Items to enrich
|
|
408
|
+
* @param {object} source - Source configuration with optional filter_bot_comments
|
|
409
|
+
* @param {object} [options] - Options passed to fetchGitHubComments
|
|
410
|
+
* @returns {Promise<Array>} Items with _comments field added
|
|
411
|
+
*/
|
|
412
|
+
export async function enrichItemsWithComments(items, source, options = {}) {
|
|
413
|
+
// Skip if not configured or not a GitHub source
|
|
414
|
+
if (!source.filter_bot_comments || source.tool?.mcp !== "github") {
|
|
415
|
+
return items;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Fetch comments for each item (could be parallelized with Promise.all for speed)
|
|
419
|
+
const enrichedItems = [];
|
|
420
|
+
for (const item of items) {
|
|
421
|
+
// Only fetch if item has comments
|
|
422
|
+
if (item.comments > 0) {
|
|
423
|
+
const comments = await fetchGitHubComments(item, options);
|
|
424
|
+
enrichedItems.push({ ...item, _comments: comments });
|
|
425
|
+
} else {
|
|
426
|
+
enrichedItems.push(item);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return enrichedItems;
|
|
431
|
+
}
|
|
432
|
+
|
|
276
433
|
/**
|
|
277
434
|
* Create a poller instance with state tracking
|
|
278
435
|
*
|
package/service/readiness.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/test/unit/poller.test.js
CHANGED
|
@@ -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');
|
|
@@ -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
|
+
});
|
package/test/unit/utils.test.js
CHANGED
|
@@ -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');
|