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 +13 -0
- package/examples/config.yaml +5 -0
- package/package.json +1 -1
- package/service/actions.js +26 -5
- package/service/poll-service.js +7 -1
- package/service/poller.js +114 -0
- package/service/presets/github.yaml +3 -0
- package/service/readiness.js +52 -0
- package/service/repo-config.js +9 -0
- package/service/utils.js +62 -0
- package/test/unit/readiness.test.js +210 -0
- package/test/unit/utils.test.js +96 -0
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:
|
package/examples/config.yaml
CHANGED
|
@@ -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
package/service/actions.js
CHANGED
|
@@ -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.
|
|
103
|
-
* 2. Exact
|
|
104
|
-
* 3.
|
|
105
|
-
* 4.
|
|
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;
|
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,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
|
*
|
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/repo-config.js
CHANGED
|
@@ -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
|
+
});
|
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');
|