opencode-pilot 0.19.0 → 0.20.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/.github/dependabot.yml +33 -0
- package/README.md +3 -2
- package/bin/opencode-pilot +149 -17
- package/package.json +1 -1
- package/plugin/index.js +51 -13
- package/service/poller.js +41 -6
- package/service/server.js +5 -3
- package/service/utils.js +71 -10
- package/service/version.js +34 -0
- package/test/unit/server.test.js +90 -0
- package/test/unit/utils.test.js +118 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "npm"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
day: "monday"
|
|
8
|
+
# Group updates to reduce PR noise
|
|
9
|
+
groups:
|
|
10
|
+
# Group all dev dependencies together
|
|
11
|
+
dev-dependencies:
|
|
12
|
+
dependency-type: "development"
|
|
13
|
+
update-types:
|
|
14
|
+
- "minor"
|
|
15
|
+
- "patch"
|
|
16
|
+
# Group production dependencies together
|
|
17
|
+
production-dependencies:
|
|
18
|
+
dependency-type: "production"
|
|
19
|
+
update-types:
|
|
20
|
+
- "minor"
|
|
21
|
+
- "patch"
|
|
22
|
+
# Keep major updates separate for careful review
|
|
23
|
+
open-pull-requests-limit: 10
|
|
24
|
+
commit-message:
|
|
25
|
+
prefix: "chore(deps)"
|
|
26
|
+
|
|
27
|
+
- package-ecosystem: "github-actions"
|
|
28
|
+
directory: "/"
|
|
29
|
+
schedule:
|
|
30
|
+
interval: "weekly"
|
|
31
|
+
day: "monday"
|
|
32
|
+
commit-message:
|
|
33
|
+
prefix: "chore(deps)"
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ npm install -g opencode-pilot
|
|
|
31
31
|
}
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The daemon will auto-start when OpenCode launches.
|
|
34
|
+
The daemon will auto-start when OpenCode launches. If a newer version of the plugin is installed, the daemon will automatically restart to pick up the new version.
|
|
35
35
|
|
|
36
36
|
Or start manually:
|
|
37
37
|
|
|
@@ -117,7 +117,8 @@ sources:
|
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
119
|
opencode-pilot start # Start the service (foreground)
|
|
120
|
-
opencode-pilot
|
|
120
|
+
opencode-pilot stop # Stop the running service
|
|
121
|
+
opencode-pilot status # Show version and service status
|
|
121
122
|
opencode-pilot config # Validate and show config
|
|
122
123
|
opencode-pilot clear # Show state summary
|
|
123
124
|
opencode-pilot clear --all # Clear all processed state
|
package/bin/opencode-pilot
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { dirname, join } from "path";
|
|
16
|
-
import { existsSync, readFileSync } from "fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
17
|
import { execSync } from "child_process";
|
|
18
18
|
import os from "os";
|
|
19
19
|
import YAML from "yaml";
|
|
@@ -39,12 +39,73 @@ function findServiceDir() {
|
|
|
39
39
|
const serviceDir = findServiceDir();
|
|
40
40
|
|
|
41
41
|
// Paths
|
|
42
|
-
const
|
|
43
|
-
const
|
|
42
|
+
const PILOT_CONFIG_DIR = join(os.homedir(), ".config/opencode/pilot");
|
|
43
|
+
const PILOT_CONFIG_FILE = join(PILOT_CONFIG_DIR, "config.yaml");
|
|
44
|
+
const PILOT_TEMPLATES_DIR = join(PILOT_CONFIG_DIR, "templates");
|
|
45
|
+
const PILOT_PID_FILE = join(PILOT_CONFIG_DIR, "pilot.pid");
|
|
44
46
|
|
|
45
47
|
// Default port
|
|
46
48
|
const DEFAULT_PORT = 4097;
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Write PID file
|
|
52
|
+
*/
|
|
53
|
+
function writePidFile() {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(PILOT_CONFIG_DIR, { recursive: true });
|
|
56
|
+
writeFileSync(PILOT_PID_FILE, String(process.pid));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn(`[opencode-pilot] Could not write PID file: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove PID file
|
|
64
|
+
*/
|
|
65
|
+
function removePidFile() {
|
|
66
|
+
try {
|
|
67
|
+
if (existsSync(PILOT_PID_FILE)) {
|
|
68
|
+
unlinkSync(PILOT_PID_FILE);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore errors on cleanup
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read PID from file
|
|
77
|
+
* @returns {number|null} PID or null if not found/invalid
|
|
78
|
+
*/
|
|
79
|
+
function readPidFile() {
|
|
80
|
+
try {
|
|
81
|
+
if (existsSync(PILOT_PID_FILE)) {
|
|
82
|
+
const content = readFileSync(PILOT_PID_FILE, "utf8").trim();
|
|
83
|
+
const pid = parseInt(content, 10);
|
|
84
|
+
if (!isNaN(pid) && pid > 0) {
|
|
85
|
+
return pid;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore errors
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a process is running
|
|
96
|
+
* @param {number} pid - Process ID
|
|
97
|
+
* @returns {boolean} True if process is running
|
|
98
|
+
*/
|
|
99
|
+
function isProcessRunning(pid) {
|
|
100
|
+
try {
|
|
101
|
+
// Signal 0 doesn't kill, just checks if process exists
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
48
109
|
/**
|
|
49
110
|
* Load port from config file
|
|
50
111
|
* @returns {number} Port number
|
|
@@ -114,7 +175,8 @@ Usage:
|
|
|
114
175
|
|
|
115
176
|
Commands:
|
|
116
177
|
start Start the polling service (foreground)
|
|
117
|
-
|
|
178
|
+
stop Stop the running service
|
|
179
|
+
status Show service status and version
|
|
118
180
|
config Validate and show configuration
|
|
119
181
|
clear Clear processed state entries
|
|
120
182
|
test-source NAME Test a source by fetching items and showing mappings
|
|
@@ -133,7 +195,8 @@ The service handles:
|
|
|
133
195
|
|
|
134
196
|
Examples:
|
|
135
197
|
opencode-pilot start # Start service (foreground)
|
|
136
|
-
opencode-pilot
|
|
198
|
+
opencode-pilot stop # Stop the service
|
|
199
|
+
opencode-pilot status # Check status and version
|
|
137
200
|
opencode-pilot config # Validate and show config
|
|
138
201
|
opencode-pilot clear --all # Clear all processed state
|
|
139
202
|
opencode-pilot clear --expired # Clear expired entries
|
|
@@ -157,6 +220,9 @@ async function startCommand() {
|
|
|
157
220
|
|
|
158
221
|
console.log("[opencode-pilot] Starting polling service...");
|
|
159
222
|
|
|
223
|
+
// Write PID file
|
|
224
|
+
writePidFile();
|
|
225
|
+
|
|
160
226
|
// Dynamic import of the service module
|
|
161
227
|
const { startService, stopService } = await import(serverPath);
|
|
162
228
|
|
|
@@ -167,29 +233,91 @@ async function startCommand() {
|
|
|
167
233
|
const service = await startService(config);
|
|
168
234
|
|
|
169
235
|
// Handle graceful shutdown
|
|
170
|
-
|
|
171
|
-
console.log(
|
|
236
|
+
const shutdown = async (signal) => {
|
|
237
|
+
console.log(`[opencode-pilot] Received ${signal}, shutting down...`);
|
|
238
|
+
removePidFile();
|
|
172
239
|
await stopService(service);
|
|
173
240
|
process.exit(0);
|
|
174
|
-
}
|
|
241
|
+
};
|
|
175
242
|
|
|
176
|
-
process.on("
|
|
177
|
-
|
|
178
|
-
await stopService(service);
|
|
179
|
-
process.exit(0);
|
|
180
|
-
});
|
|
243
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
244
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
181
245
|
|
|
182
246
|
// Keep running until signal received
|
|
183
247
|
await new Promise(() => {});
|
|
184
248
|
}
|
|
185
249
|
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Stop Command
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
function stopCommand() {
|
|
255
|
+
const pid = readPidFile();
|
|
256
|
+
|
|
257
|
+
if (!pid) {
|
|
258
|
+
console.log("Service is not running (no PID file found)");
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!isProcessRunning(pid)) {
|
|
263
|
+
console.log(`Service is not running (stale PID file for pid ${pid})`);
|
|
264
|
+
removePidFile();
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log(`Stopping opencode-pilot (pid ${pid})...`);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
process.kill(pid, "SIGTERM");
|
|
272
|
+
|
|
273
|
+
// Wait for process to exit (up to 5 seconds)
|
|
274
|
+
let attempts = 0;
|
|
275
|
+
const maxAttempts = 50;
|
|
276
|
+
const checkInterval = 100;
|
|
277
|
+
|
|
278
|
+
const waitForExit = () => {
|
|
279
|
+
if (!isProcessRunning(pid)) {
|
|
280
|
+
console.log("Service stopped");
|
|
281
|
+
process.exit(0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
attempts++;
|
|
285
|
+
if (attempts >= maxAttempts) {
|
|
286
|
+
console.log("Service did not stop gracefully, sending SIGKILL...");
|
|
287
|
+
try {
|
|
288
|
+
process.kill(pid, "SIGKILL");
|
|
289
|
+
} catch {
|
|
290
|
+
// Process may have exited
|
|
291
|
+
}
|
|
292
|
+
removePidFile();
|
|
293
|
+
console.log("Service killed");
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
setTimeout(waitForExit, checkInterval);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
setTimeout(waitForExit, checkInterval);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err.code === "ESRCH") {
|
|
303
|
+
console.log("Service is not running");
|
|
304
|
+
removePidFile();
|
|
305
|
+
} else {
|
|
306
|
+
console.error(`Failed to stop service: ${err.message}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
186
312
|
// ============================================================================
|
|
187
313
|
// Status Command
|
|
188
314
|
// ============================================================================
|
|
189
315
|
|
|
190
|
-
function statusCommand() {
|
|
191
|
-
|
|
192
|
-
|
|
316
|
+
async function statusCommand() {
|
|
317
|
+
const { getVersion } = await import(join(serviceDir, "version.js"));
|
|
318
|
+
const version = getVersion();
|
|
319
|
+
console.log(`opencode-pilot v${version}`);
|
|
320
|
+
console.log("=".repeat(`opencode-pilot v${version}`.length));
|
|
193
321
|
console.log("");
|
|
194
322
|
|
|
195
323
|
// Service running? Check if HTTP responds
|
|
@@ -702,8 +830,12 @@ async function main() {
|
|
|
702
830
|
await startCommand();
|
|
703
831
|
break;
|
|
704
832
|
|
|
833
|
+
case "stop":
|
|
834
|
+
stopCommand();
|
|
835
|
+
break;
|
|
836
|
+
|
|
705
837
|
case "status":
|
|
706
|
-
statusCommand();
|
|
838
|
+
await statusCommand();
|
|
707
839
|
break;
|
|
708
840
|
|
|
709
841
|
case "config":
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Add "opencode-pilot" to your opencode.json plugins array to enable.
|
|
4
4
|
// The plugin checks if the daemon is running and starts it if needed.
|
|
5
|
+
// If the running daemon has a different version, it will be restarted.
|
|
5
6
|
|
|
6
7
|
import { existsSync, readFileSync } from 'fs'
|
|
7
|
-
import { spawn } from 'child_process'
|
|
8
|
+
import { spawn, spawnSync } from 'child_process'
|
|
8
9
|
import { join } from 'path'
|
|
9
10
|
import { homedir } from 'os'
|
|
10
11
|
import YAML from 'yaml'
|
|
12
|
+
import { getVersion } from '../service/version.js'
|
|
11
13
|
|
|
12
14
|
const DEFAULT_PORT = 4097
|
|
13
15
|
const CONFIG_PATH = join(homedir(), '.config', 'opencode', 'pilot', 'config.yaml')
|
|
@@ -32,31 +34,67 @@ function getPort() {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
|
-
*
|
|
37
|
+
* Start the daemon as a detached background process
|
|
38
|
+
*/
|
|
39
|
+
function startDaemon() {
|
|
40
|
+
try {
|
|
41
|
+
const child = spawn('npx', ['opencode-pilot', 'start'], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: 'ignore',
|
|
44
|
+
})
|
|
45
|
+
child.unref()
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore start errors
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stop the daemon synchronously
|
|
53
|
+
*/
|
|
54
|
+
function stopDaemon() {
|
|
55
|
+
try {
|
|
56
|
+
spawnSync('npx', ['opencode-pilot', 'stop'], {
|
|
57
|
+
stdio: 'ignore',
|
|
58
|
+
timeout: 10000,
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore stop errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* OpenCode plugin that auto-starts the daemon if not running.
|
|
67
|
+
* If a daemon is running with a different version, it will be restarted.
|
|
36
68
|
*/
|
|
37
69
|
export const PilotPlugin = async () => {
|
|
38
70
|
const port = getPort()
|
|
71
|
+
const ourVersion = getVersion()
|
|
39
72
|
|
|
40
73
|
try {
|
|
41
74
|
// Check if daemon is already running
|
|
42
75
|
const res = await fetch(`http://localhost:${port}/health`, {
|
|
43
76
|
signal: AbortSignal.timeout(1000)
|
|
44
77
|
})
|
|
78
|
+
|
|
45
79
|
if (res.ok) {
|
|
46
|
-
//
|
|
80
|
+
// Check version
|
|
81
|
+
const data = await res.json()
|
|
82
|
+
const runningVersion = data.version
|
|
83
|
+
|
|
84
|
+
if (runningVersion && runningVersion !== ourVersion && ourVersion !== 'unknown') {
|
|
85
|
+
// Version mismatch - restart daemon
|
|
86
|
+
console.log(`[opencode-pilot] Version mismatch (running: ${runningVersion}, plugin: ${ourVersion}), restarting...`)
|
|
87
|
+
stopDaemon()
|
|
88
|
+
// Small delay to ensure port is released
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
90
|
+
startDaemon()
|
|
91
|
+
}
|
|
92
|
+
// else: same version, nothing to do
|
|
47
93
|
return {}
|
|
48
94
|
}
|
|
49
95
|
} catch {
|
|
50
|
-
// Not running, start it
|
|
51
|
-
|
|
52
|
-
const child = spawn('npx', ['opencode-pilot', 'start'], {
|
|
53
|
-
detached: true,
|
|
54
|
-
stdio: 'ignore',
|
|
55
|
-
})
|
|
56
|
-
child.unref()
|
|
57
|
-
} catch {
|
|
58
|
-
// Ignore start errors
|
|
59
|
-
}
|
|
96
|
+
// Not running or error, start it
|
|
97
|
+
startDaemon()
|
|
60
98
|
}
|
|
61
99
|
|
|
62
100
|
return {}
|
package/service/poller.js
CHANGED
|
@@ -437,21 +437,54 @@ async function fetchPrReviewCommentsViaCli(owner, repo, number, timeout) {
|
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Fetch PR reviews using gh CLI
|
|
442
|
+
*
|
|
443
|
+
* Fetches formal PR reviews (APPROVED, CHANGES_REQUESTED, COMMENTED state).
|
|
444
|
+
* These are separate from inline comments and issue comments.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} owner - Repository owner
|
|
447
|
+
* @param {string} repo - Repository name
|
|
448
|
+
* @param {number} number - PR number
|
|
449
|
+
* @param {number} timeout - Timeout in ms
|
|
450
|
+
* @returns {Promise<Array>} Array of review objects with user, state, body
|
|
451
|
+
*/
|
|
452
|
+
async function fetchPrReviewsViaCli(owner, repo, number, timeout) {
|
|
453
|
+
const { exec } = await import('child_process');
|
|
454
|
+
const { promisify } = await import('util');
|
|
455
|
+
const execAsync = promisify(exec);
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const { stdout } = await Promise.race([
|
|
459
|
+
execAsync(`gh api repos/${owner}/${repo}/pulls/${number}/reviews`),
|
|
460
|
+
createTimeout(timeout, "gh api call for PR reviews"),
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
const reviews = JSON.parse(stdout);
|
|
464
|
+
return Array.isArray(reviews) ? reviews : [];
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.error(`[poller] Error fetching PR reviews via gh: ${err.message}`);
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
440
471
|
/**
|
|
441
472
|
* Fetch comments for a GitHub issue/PR and enrich the item
|
|
442
473
|
*
|
|
443
|
-
* Fetches
|
|
474
|
+
* Fetches THREE types of feedback using gh CLI:
|
|
444
475
|
* 1. PR review comments (inline code comments) via gh api pulls/{number}/comments
|
|
445
476
|
* 2. Issue comments (conversation thread) via gh api issues/{number}/comments
|
|
477
|
+
* 3. PR reviews (formal reviews) via gh api pulls/{number}/reviews
|
|
446
478
|
*
|
|
447
479
|
* This is necessary because:
|
|
448
480
|
* - Bots like Linear post to issue comments, not PR review comments
|
|
449
481
|
* - Human reviewers post inline feedback as PR review comments
|
|
482
|
+
* - Formal PR reviews (APPROVED, CHANGES_REQUESTED, COMMENTED) are stored separately
|
|
450
483
|
*
|
|
451
484
|
* @param {object} item - Item with repository_full_name and number fields
|
|
452
485
|
* @param {object} [options] - Options
|
|
453
486
|
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
454
|
-
* @returns {Promise<Array>} Array of comment objects (merged from
|
|
487
|
+
* @returns {Promise<Array>} Array of comment/review objects (merged from all endpoints)
|
|
455
488
|
*/
|
|
456
489
|
export async function fetchGitHubComments(item, options = {}) {
|
|
457
490
|
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
@@ -473,16 +506,18 @@ export async function fetchGitHubComments(item, options = {}) {
|
|
|
473
506
|
}
|
|
474
507
|
|
|
475
508
|
try {
|
|
476
|
-
// Fetch
|
|
477
|
-
const [prComments, issueComments] = await Promise.all([
|
|
509
|
+
// Fetch PR review comments, issue comments, AND PR reviews in parallel via gh CLI
|
|
510
|
+
const [prComments, issueComments, prReviews] = await Promise.all([
|
|
478
511
|
// PR review comments (inline code comments from reviewers)
|
|
479
512
|
fetchPrReviewCommentsViaCli(owner, repo, number, timeout),
|
|
480
513
|
// Issue comments (conversation thread where Linear bot posts)
|
|
481
514
|
fetchIssueCommentsViaCli(owner, repo, number, timeout),
|
|
515
|
+
// PR reviews (formal reviews: APPROVED, CHANGES_REQUESTED, COMMENTED)
|
|
516
|
+
fetchPrReviewsViaCli(owner, repo, number, timeout),
|
|
482
517
|
]);
|
|
483
518
|
|
|
484
|
-
// Return merged
|
|
485
|
-
return [...prComments, ...issueComments];
|
|
519
|
+
// Return merged feedback from all sources
|
|
520
|
+
return [...prComments, ...issueComments, ...prReviews];
|
|
486
521
|
} catch (err) {
|
|
487
522
|
console.error(`[poller] Error fetching comments: ${err.message}`);
|
|
488
523
|
return [];
|
package/service/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url'
|
|
|
10
10
|
import { homedir } from 'os'
|
|
11
11
|
import { join } from 'path'
|
|
12
12
|
import YAML from 'yaml'
|
|
13
|
+
import { getVersion } from './version.js'
|
|
13
14
|
|
|
14
15
|
// Default configuration
|
|
15
16
|
const DEFAULT_HTTP_PORT = 4097
|
|
@@ -56,10 +57,11 @@ function createHttpServer_(port) {
|
|
|
56
57
|
return
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// GET /health - Health check
|
|
60
|
+
// GET /health - Health check with version
|
|
60
61
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
61
|
-
|
|
62
|
-
res.
|
|
62
|
+
const version = getVersion()
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
64
|
+
res.end(JSON.stringify({ status: 'ok', version }))
|
|
63
65
|
return
|
|
64
66
|
}
|
|
65
67
|
|
package/service/utils.js
CHANGED
|
@@ -70,17 +70,64 @@ export function isBot(username, type) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
* Check if a PR
|
|
73
|
+
* Check if feedback is a PR review (has state field from /pulls/{number}/reviews)
|
|
74
|
+
*
|
|
75
|
+
* PR reviews have a state field: APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED
|
|
76
|
+
* Regular comments (from /issues/{number}/comments or /pulls/{number}/comments) don't have state.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} feedback - Comment or review object
|
|
79
|
+
* @returns {boolean} True if this is a PR review (not a regular comment)
|
|
80
|
+
*/
|
|
81
|
+
export function isPrReview(feedback) {
|
|
82
|
+
return feedback && typeof feedback.state === 'string';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if feedback is an inline PR comment (from /pulls/{number}/comments)
|
|
87
|
+
*
|
|
88
|
+
* Inline comments have path, position, or diff_hunk fields that top-level comments don't have.
|
|
89
|
+
* They may also have in_reply_to_id if they're replies to other inline comments.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} feedback - Comment or review object
|
|
92
|
+
* @returns {boolean} True if this is an inline PR comment
|
|
93
|
+
*/
|
|
94
|
+
export function isInlineComment(feedback) {
|
|
95
|
+
if (!feedback) return false;
|
|
96
|
+
// Inline comments have path (file path) and usually diff_hunk or position
|
|
97
|
+
return typeof feedback.path === 'string' || typeof feedback.diff_hunk === 'string';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if feedback is a reply to another comment
|
|
102
|
+
*
|
|
103
|
+
* @param {object} feedback - Comment or review object
|
|
104
|
+
* @returns {boolean} True if this is a reply
|
|
105
|
+
*/
|
|
106
|
+
export function isReply(feedback) {
|
|
107
|
+
if (!feedback) return false;
|
|
108
|
+
// PR review comments use in_reply_to_id for replies
|
|
109
|
+
return feedback.in_reply_to_id !== undefined && feedback.in_reply_to_id !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if a PR/issue has actionable feedback
|
|
74
114
|
*
|
|
75
115
|
* Used to filter out PRs where only bots have commented, since those don't
|
|
76
|
-
* require the author's attention
|
|
116
|
+
* require the author's attention.
|
|
117
|
+
*
|
|
118
|
+
* Logic for author's own feedback:
|
|
119
|
+
* - Author's inline comments (standalone) → trigger (self-review on code)
|
|
120
|
+
* - Author's inline comments (replies) → ignore (responding to reviewer)
|
|
121
|
+
* - Author's PR reviews → trigger (formal self-review)
|
|
122
|
+
* - Author's top-level comments → ignore (conversation noise)
|
|
77
123
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
124
|
+
* Logic for others' feedback:
|
|
125
|
+
* - Bot comments → ignore
|
|
126
|
+
* - Human comments/reviews → trigger (except approval-only with no body)
|
|
80
127
|
*
|
|
81
|
-
* @param {Array} comments - Array of comment objects with user.login and user.type
|
|
128
|
+
* @param {Array} comments - Array of comment/review objects with user.login and user.type
|
|
82
129
|
* @param {string} authorUsername - Username of the PR/issue author
|
|
83
|
-
* @returns {boolean} True if there's at least one
|
|
130
|
+
* @returns {boolean} True if there's at least one actionable feedback item
|
|
84
131
|
*/
|
|
85
132
|
export function hasNonBotFeedback(comments, authorUsername) {
|
|
86
133
|
// Handle null/undefined/empty
|
|
@@ -97,16 +144,30 @@ export function hasNonBotFeedback(comments, authorUsername) {
|
|
|
97
144
|
const username = user.login;
|
|
98
145
|
const userType = user.type;
|
|
99
146
|
|
|
100
|
-
// Skip if it's a bot
|
|
147
|
+
// Skip if it's a bot (but Copilot is NOT in bot list, so Copilot reviews are kept)
|
|
101
148
|
if (isBot(username, userType)) continue;
|
|
102
149
|
|
|
103
|
-
//
|
|
104
|
-
if (authorLower && username?.toLowerCase() === authorLower)
|
|
150
|
+
// For author's own feedback, apply special rules
|
|
151
|
+
if (authorLower && username?.toLowerCase() === authorLower) {
|
|
152
|
+
// Author's PR reviews → trigger
|
|
153
|
+
if (isPrReview(comment)) {
|
|
154
|
+
// Continue to check if it's actionable (not approval-only)
|
|
155
|
+
}
|
|
156
|
+
// Author's inline comments (standalone only) → trigger
|
|
157
|
+
else if (isInlineComment(comment)) {
|
|
158
|
+
if (isReply(comment)) continue; // Skip replies
|
|
159
|
+
// Standalone inline comment - continue to actionable check
|
|
160
|
+
}
|
|
161
|
+
// Author's top-level comments → ignore
|
|
162
|
+
else {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
105
166
|
|
|
106
167
|
// Skip approval-only reviews (no actionable feedback)
|
|
107
168
|
if (isApprovalOnly(comment)) continue;
|
|
108
169
|
|
|
109
|
-
// Found
|
|
170
|
+
// Found actionable feedback
|
|
110
171
|
return true;
|
|
111
172
|
}
|
|
112
173
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared version utility for opencode-pilot
|
|
2
|
+
//
|
|
3
|
+
// Returns the version from package.json
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'fs'
|
|
6
|
+
import { join, dirname } from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = dirname(__filename)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get version from package.json
|
|
14
|
+
* Checks multiple locations for compatibility with different install methods
|
|
15
|
+
* @returns {string} Version string or 'unknown'
|
|
16
|
+
*/
|
|
17
|
+
export function getVersion() {
|
|
18
|
+
const candidates = [
|
|
19
|
+
join(__dirname, '..', 'package.json'), // Development: service/../package.json
|
|
20
|
+
join(__dirname, '..', '..', 'package.json'), // Homebrew: libexec/../package.json
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
for (const packagePath of candidates) {
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(packagePath)) {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
27
|
+
if (pkg.version) return pkg.version
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Try next candidate
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return 'unknown'
|
|
34
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for service/server.js - HTTP server and health endpoint
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, afterEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
|
|
14
|
+
// Get expected version from package.json
|
|
15
|
+
function getExpectedVersion() {
|
|
16
|
+
const packagePath = join(__dirname, '..', '..', 'package.json');
|
|
17
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
18
|
+
return pkg.version;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('service/server.js', () => {
|
|
22
|
+
let service = null;
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
if (service) {
|
|
26
|
+
const { stopService } = await import('../../service/server.js');
|
|
27
|
+
await stopService(service);
|
|
28
|
+
service = null;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('health endpoint', () => {
|
|
33
|
+
test('returns JSON with status and version', async () => {
|
|
34
|
+
const { startService, stopService } = await import('../../service/server.js');
|
|
35
|
+
|
|
36
|
+
// Start service on random port
|
|
37
|
+
service = await startService({
|
|
38
|
+
httpPort: 0, // Let OS assign port
|
|
39
|
+
enablePolling: false
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const port = service.httpServer.address().port;
|
|
43
|
+
const res = await fetch(`http://localhost:${port}/health`);
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(res.status, 200);
|
|
46
|
+
assert.strictEqual(res.headers.get('content-type'), 'application/json');
|
|
47
|
+
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
assert.strictEqual(data.status, 'ok');
|
|
50
|
+
assert.strictEqual(typeof data.version, 'string');
|
|
51
|
+
assert.strictEqual(data.version, getExpectedVersion());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('CORS', () => {
|
|
57
|
+
test('OPTIONS returns CORS headers', async () => {
|
|
58
|
+
const { startService } = await import('../../service/server.js');
|
|
59
|
+
|
|
60
|
+
service = await startService({
|
|
61
|
+
httpPort: 0,
|
|
62
|
+
enablePolling: false
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const port = service.httpServer.address().port;
|
|
66
|
+
const res = await fetch(`http://localhost:${port}/health`, {
|
|
67
|
+
method: 'OPTIONS'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.strictEqual(res.status, 204);
|
|
71
|
+
assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('unknown routes', () => {
|
|
76
|
+
test('returns 404 for unknown paths', async () => {
|
|
77
|
+
const { startService } = await import('../../service/server.js');
|
|
78
|
+
|
|
79
|
+
service = await startService({
|
|
80
|
+
httpPort: 0,
|
|
81
|
+
enablePolling: false
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const port = service.httpServer.address().port;
|
|
85
|
+
const res = await fetch(`http://localhost:${port}/unknown`);
|
|
86
|
+
|
|
87
|
+
assert.strictEqual(res.status, 404);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
package/test/unit/utils.test.js
CHANGED
|
@@ -71,9 +71,10 @@ describe('utils.js', () => {
|
|
|
71
71
|
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
test('returns false when only author has
|
|
74
|
+
test('returns false when only author has top-level comments', async () => {
|
|
75
75
|
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
76
76
|
|
|
77
|
+
// Top-level comments from author (no state, no path) are ignored
|
|
77
78
|
const comments = [
|
|
78
79
|
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
79
80
|
{ user: { login: 'athal7', type: 'User' }, body: 'Added screenshots' },
|
|
@@ -82,6 +83,62 @@ describe('utils.js', () => {
|
|
|
82
83
|
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test('returns true when author has submitted a PR review (self-review)', async () => {
|
|
87
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
88
|
+
|
|
89
|
+
// PR reviews from author (have state field) ARE actionable - self-review feedback
|
|
90
|
+
const comments = [
|
|
91
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
92
|
+
{ user: { login: 'athal7', type: 'User' }, state: 'COMMENTED', body: 'TODO: add tests' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns true when author has standalone inline comment', async () => {
|
|
99
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
100
|
+
|
|
101
|
+
// Standalone inline comments from author (have path, no in_reply_to_id) ARE actionable
|
|
102
|
+
const comments = [
|
|
103
|
+
{
|
|
104
|
+
user: { login: 'athal7', type: 'User' },
|
|
105
|
+
body: 'Need to refactor this',
|
|
106
|
+
path: 'src/index.js',
|
|
107
|
+
diff_hunk: '@@ -1,3 +1,4 @@',
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('returns false when author has reply inline comment', async () => {
|
|
115
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
116
|
+
|
|
117
|
+
// Reply inline comments from author (have in_reply_to_id) are ignored
|
|
118
|
+
const comments = [
|
|
119
|
+
{
|
|
120
|
+
user: { login: 'athal7', type: 'User' },
|
|
121
|
+
body: 'Fixed!',
|
|
122
|
+
path: 'src/index.js',
|
|
123
|
+
diff_hunk: '@@ -1,3 +1,4 @@',
|
|
124
|
+
in_reply_to_id: 12345,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('returns false when author self-approves with no feedback', async () => {
|
|
132
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
133
|
+
|
|
134
|
+
// Author's approval-only (no body) should not trigger
|
|
135
|
+
const comments = [
|
|
136
|
+
{ user: { login: 'athal7', type: 'User' }, state: 'APPROVED', body: '' },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'athal7'), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
85
142
|
test('returns false for empty comments array', async () => {
|
|
86
143
|
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
87
144
|
|
|
@@ -173,6 +230,66 @@ describe('utils.js', () => {
|
|
|
173
230
|
});
|
|
174
231
|
});
|
|
175
232
|
|
|
233
|
+
describe('isPrReview', () => {
|
|
234
|
+
test('returns true for objects with state field', async () => {
|
|
235
|
+
const { isPrReview } = await import('../../service/utils.js');
|
|
236
|
+
|
|
237
|
+
assert.strictEqual(isPrReview({ state: 'APPROVED' }), true);
|
|
238
|
+
assert.strictEqual(isPrReview({ state: 'CHANGES_REQUESTED' }), true);
|
|
239
|
+
assert.strictEqual(isPrReview({ state: 'COMMENTED' }), true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('returns false for objects without state field', async () => {
|
|
243
|
+
const { isPrReview } = await import('../../service/utils.js');
|
|
244
|
+
|
|
245
|
+
assert.strictEqual(isPrReview({ body: 'Comment' }), false);
|
|
246
|
+
assert.strictEqual(isPrReview({}), false);
|
|
247
|
+
// null/undefined returns falsy (null), which is fine for boolean checks
|
|
248
|
+
assert.ok(!isPrReview(null));
|
|
249
|
+
assert.ok(!isPrReview(undefined));
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('isInlineComment', () => {
|
|
254
|
+
test('returns true for comments with path field', async () => {
|
|
255
|
+
const { isInlineComment } = await import('../../service/utils.js');
|
|
256
|
+
|
|
257
|
+
assert.strictEqual(isInlineComment({ path: 'src/index.js', body: 'Fix this' }), true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('returns true for comments with diff_hunk field', async () => {
|
|
261
|
+
const { isInlineComment } = await import('../../service/utils.js');
|
|
262
|
+
|
|
263
|
+
assert.strictEqual(isInlineComment({ diff_hunk: '@@ -1,3 +1,4 @@', body: 'Nice' }), true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('returns false for top-level comments', async () => {
|
|
267
|
+
const { isInlineComment } = await import('../../service/utils.js');
|
|
268
|
+
|
|
269
|
+
assert.strictEqual(isInlineComment({ body: 'Great work!' }), false);
|
|
270
|
+
assert.strictEqual(isInlineComment({}), false);
|
|
271
|
+
assert.strictEqual(isInlineComment(null), false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('isReply', () => {
|
|
276
|
+
test('returns true for comments with in_reply_to_id', async () => {
|
|
277
|
+
const { isReply } = await import('../../service/utils.js');
|
|
278
|
+
|
|
279
|
+
assert.strictEqual(isReply({ in_reply_to_id: 12345 }), true);
|
|
280
|
+
assert.strictEqual(isReply({ in_reply_to_id: 0 }), true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('returns false for standalone comments', async () => {
|
|
284
|
+
const { isReply } = await import('../../service/utils.js');
|
|
285
|
+
|
|
286
|
+
assert.strictEqual(isReply({ body: 'Standalone' }), false);
|
|
287
|
+
assert.strictEqual(isReply({ in_reply_to_id: null }), false);
|
|
288
|
+
assert.strictEqual(isReply({ in_reply_to_id: undefined }), false);
|
|
289
|
+
assert.strictEqual(isReply(null), false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
176
293
|
describe('getNestedValue', () => {
|
|
177
294
|
test('gets top-level value', async () => {
|
|
178
295
|
const { getNestedValue } = await import('../../service/utils.js');
|