opencode-pilot 0.18.3 → 0.19.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/.github/dependabot.yml +33 -0
- package/README.md +20 -2
- package/examples/config.yaml +7 -1
- package/package.json +4 -2
- package/service/actions.js +284 -0
- package/service/poller.js +41 -6
- package/service/utils.js +71 -10
- package/service/worktree.js +50 -6
- package/test/integration/session-reuse.test.js +347 -0
- package/test/unit/actions.test.js +295 -2
- package/test/unit/utils.test.js +118 -1
- package/test/unit/worktree.test.js +150 -9
|
@@ -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
|
@@ -74,6 +74,23 @@ Session names for `my-prs-attention` indicate the condition: "Conflicts: {title}
|
|
|
74
74
|
|
|
75
75
|
Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
|
|
76
76
|
|
|
77
|
+
### Session and Sandbox Reuse
|
|
78
|
+
|
|
79
|
+
By default, pilot reuses existing sessions and sandboxes to avoid duplicates:
|
|
80
|
+
|
|
81
|
+
- **Session reuse**: If a non-archived session already exists for the target directory, pilot appends to it instead of creating a new session. Archived sessions are never reused.
|
|
82
|
+
- **Sandbox reuse**: When `worktree: "new"` with a `worktree_name`, pilot first checks if a sandbox with that name already exists and reuses it.
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
defaults:
|
|
86
|
+
# Disable session reuse (always create new sessions)
|
|
87
|
+
reuse_active_session: false
|
|
88
|
+
# Disable sandbox reuse (always create new worktrees)
|
|
89
|
+
prefer_existing_sandbox: false
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
When multiple sessions exist for the same directory, pilot prefers idle sessions over busy ones, then selects the most recently updated.
|
|
93
|
+
|
|
77
94
|
### Worktree Support
|
|
78
95
|
|
|
79
96
|
Run sessions in isolated git worktrees instead of the main project directory. This uses OpenCode's built-in worktree management API to create and manage worktrees.
|
|
@@ -81,7 +98,7 @@ Run sessions in isolated git worktrees instead of the main project directory. Th
|
|
|
81
98
|
```yaml
|
|
82
99
|
sources:
|
|
83
100
|
- preset: github/my-issues
|
|
84
|
-
# Create a fresh worktree for each session
|
|
101
|
+
# Create a fresh worktree for each session (or reuse if name matches)
|
|
85
102
|
worktree: "new"
|
|
86
103
|
worktree_name: "issue-{number}" # Optional: name template
|
|
87
104
|
|
|
@@ -91,9 +108,10 @@ sources:
|
|
|
91
108
|
```
|
|
92
109
|
|
|
93
110
|
**Options:**
|
|
94
|
-
- `worktree: "new"` - Create a new worktree via OpenCode's API
|
|
111
|
+
- `worktree: "new"` - Create a new worktree via OpenCode's API (or reuse existing if name matches)
|
|
95
112
|
- `worktree: "name"` - Look up existing worktree by name from project sandboxes
|
|
96
113
|
- `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
|
|
114
|
+
- `prefer_existing_sandbox: false` - Disable sandbox reuse for this source
|
|
97
115
|
|
|
98
116
|
## CLI Commands
|
|
99
117
|
|
package/examples/config.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Example config.yaml for opencode-pilot
|
|
2
|
-
# Copy to ~/.config/opencode
|
|
2
|
+
# Copy to ~/.config/opencode/pilot/config.yaml
|
|
3
3
|
|
|
4
4
|
# Preferred OpenCode server port for attaching sessions
|
|
5
5
|
# When multiple OpenCode instances are running, pilot will attach new sessions
|
|
@@ -19,6 +19,12 @@ repos_dir: ~/code
|
|
|
19
19
|
defaults:
|
|
20
20
|
agent: plan
|
|
21
21
|
prompt: default
|
|
22
|
+
# Session reuse: append to existing non-archived session instead of creating new
|
|
23
|
+
# Default: true. Set to false to always create new sessions.
|
|
24
|
+
# reuse_active_session: true
|
|
25
|
+
# Sandbox reuse: reuse existing worktree/sandbox with matching name
|
|
26
|
+
# Default: true. Set to false to always create new sandboxes.
|
|
27
|
+
# prefer_existing_sandbox: true
|
|
22
28
|
|
|
23
29
|
sources:
|
|
24
30
|
# Presets - common patterns with sensible defaults
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"description": "Automation daemon for OpenCode - polls for work and spawns sessions",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"opencode-pilot": "./bin/opencode-pilot"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test test/unit/*.test.js"
|
|
28
|
+
"test": "node --test test/unit/*.test.js",
|
|
29
|
+
"test:integration": "node --test test/integration/*.test.js",
|
|
30
|
+
"test:all": "node --test test/unit/*.test.js test/integration/*.test.js"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@semantic-release/git": "^10.0.1",
|
package/service/actions.js
CHANGED
|
@@ -306,6 +306,258 @@ export function buildCommand(item, config) {
|
|
|
306
306
|
return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* List sessions from the OpenCode server
|
|
311
|
+
*
|
|
312
|
+
* @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
|
|
313
|
+
* @param {object} [options] - Options
|
|
314
|
+
* @param {string} [options.directory] - Filter by directory
|
|
315
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
316
|
+
* @returns {Promise<Array>} Array of session objects
|
|
317
|
+
*/
|
|
318
|
+
export async function listSessions(serverUrl, options = {}) {
|
|
319
|
+
const fetchFn = options.fetch || fetch;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const url = new URL('/session', serverUrl);
|
|
323
|
+
if (options.directory) {
|
|
324
|
+
url.searchParams.set('directory', options.directory);
|
|
325
|
+
}
|
|
326
|
+
// Only get root sessions (not child/forked sessions)
|
|
327
|
+
url.searchParams.set('roots', 'true');
|
|
328
|
+
|
|
329
|
+
const response = await fetchFn(url.toString());
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
debug(`listSessions: ${serverUrl} returned ${response.status}`);
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sessions = await response.json();
|
|
337
|
+
return Array.isArray(sessions) ? sessions : [];
|
|
338
|
+
} catch (err) {
|
|
339
|
+
debug(`listSessions: error - ${err.message}`);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if a session is archived
|
|
346
|
+
* A session is archived if time.archived is set (it's a timestamp)
|
|
347
|
+
*
|
|
348
|
+
* @param {object} session - Session object from API
|
|
349
|
+
* @returns {boolean} True if session is archived
|
|
350
|
+
*/
|
|
351
|
+
export function isSessionArchived(session) {
|
|
352
|
+
return session?.time?.archived !== undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get session statuses from the OpenCode server
|
|
357
|
+
* Returns a map of sessionId -> status (idle, busy, retry)
|
|
358
|
+
* Sessions not in the map are considered idle
|
|
359
|
+
*
|
|
360
|
+
* @param {string} serverUrl - Server URL
|
|
361
|
+
* @param {object} [options] - Options
|
|
362
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
363
|
+
* @returns {Promise<object>} Map of sessionId -> status object
|
|
364
|
+
*/
|
|
365
|
+
export async function getSessionStatuses(serverUrl, options = {}) {
|
|
366
|
+
const fetchFn = options.fetch || fetch;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetchFn(`${serverUrl}/session/status`);
|
|
370
|
+
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
debug(`getSessionStatuses: ${serverUrl} returned ${response.status}`);
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return await response.json();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
debug(`getSessionStatuses: error - ${err.message}`);
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Find the best session to reuse from a list of candidates
|
|
385
|
+
* Prefers idle sessions, then most recently updated
|
|
386
|
+
*
|
|
387
|
+
* @param {Array} sessions - Array of non-archived sessions
|
|
388
|
+
* @param {object} statuses - Map of sessionId -> status from /session/status
|
|
389
|
+
* @returns {object|null} Best session to reuse, or null if none
|
|
390
|
+
*/
|
|
391
|
+
export function selectBestSession(sessions, statuses) {
|
|
392
|
+
if (!sessions || sessions.length === 0) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Separate idle vs busy/retry sessions
|
|
397
|
+
const idle = [];
|
|
398
|
+
const other = [];
|
|
399
|
+
|
|
400
|
+
for (const session of sessions) {
|
|
401
|
+
const status = statuses[session.id];
|
|
402
|
+
// Sessions not in statuses map are idle (per OpenCode behavior)
|
|
403
|
+
if (!status || status.type === 'idle') {
|
|
404
|
+
idle.push(session);
|
|
405
|
+
} else {
|
|
406
|
+
other.push(session);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Sort by most recently updated (highest time.updated first)
|
|
411
|
+
const sortByUpdated = (a, b) => (b.time?.updated || 0) - (a.time?.updated || 0);
|
|
412
|
+
|
|
413
|
+
// Prefer idle sessions
|
|
414
|
+
if (idle.length > 0) {
|
|
415
|
+
idle.sort(sortByUpdated);
|
|
416
|
+
return idle[0];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Fall back to busy/retry sessions (sorted by most recent)
|
|
420
|
+
if (other.length > 0) {
|
|
421
|
+
other.sort(sortByUpdated);
|
|
422
|
+
return other[0];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Send a message to an existing session
|
|
430
|
+
*
|
|
431
|
+
* @param {string} serverUrl - Server URL
|
|
432
|
+
* @param {string} sessionId - Session ID to send message to
|
|
433
|
+
* @param {string} directory - Working directory
|
|
434
|
+
* @param {string} prompt - The prompt/message to send
|
|
435
|
+
* @param {object} [options] - Options
|
|
436
|
+
* @param {string} [options.title] - Update session title (optional)
|
|
437
|
+
* @param {string} [options.agent] - Agent to use
|
|
438
|
+
* @param {string} [options.model] - Model to use
|
|
439
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
440
|
+
* @returns {Promise<object>} Result with sessionId, success, error
|
|
441
|
+
*/
|
|
442
|
+
export async function sendMessageToSession(serverUrl, sessionId, directory, prompt, options = {}) {
|
|
443
|
+
const fetchFn = options.fetch || fetch;
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
// Step 1: Update session title if provided
|
|
447
|
+
if (options.title) {
|
|
448
|
+
const updateUrl = new URL(`/session/${sessionId}`, serverUrl);
|
|
449
|
+
updateUrl.searchParams.set('directory', directory);
|
|
450
|
+
await fetchFn(updateUrl.toString(), {
|
|
451
|
+
method: 'PATCH',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify({ title: options.title }),
|
|
454
|
+
});
|
|
455
|
+
debug(`sendMessageToSession: updated title for session ${sessionId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Step 2: Send the message
|
|
459
|
+
const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
|
|
460
|
+
messageUrl.searchParams.set('directory', directory);
|
|
461
|
+
|
|
462
|
+
const messageBody = {
|
|
463
|
+
parts: [{ type: 'text', text: prompt }],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
if (options.agent) {
|
|
467
|
+
messageBody.agent = options.agent;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (options.model) {
|
|
471
|
+
const [providerID, modelID] = options.model.includes('/')
|
|
472
|
+
? options.model.split('/', 2)
|
|
473
|
+
: ['anthropic', options.model];
|
|
474
|
+
messageBody.providerID = providerID;
|
|
475
|
+
messageBody.modelID = modelID;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Use AbortController with timeout (same pattern as createSessionViaApi)
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const messageResponse = await fetchFn(messageUrl.toString(), {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: { 'Content-Type': 'application/json' },
|
|
486
|
+
body: JSON.stringify(messageBody),
|
|
487
|
+
signal: controller.signal,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
|
|
492
|
+
if (!messageResponse.ok) {
|
|
493
|
+
const errorText = await messageResponse.text();
|
|
494
|
+
throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
debug(`sendMessageToSession: sent message to session ${sessionId}`);
|
|
498
|
+
} catch (abortErr) {
|
|
499
|
+
clearTimeout(timeoutId);
|
|
500
|
+
if (abortErr.name === 'AbortError') {
|
|
501
|
+
debug(`sendMessageToSession: message request started for session ${sessionId} (response aborted as expected)`);
|
|
502
|
+
} else {
|
|
503
|
+
throw abortErr;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
success: true,
|
|
509
|
+
sessionId,
|
|
510
|
+
directory,
|
|
511
|
+
reused: true,
|
|
512
|
+
};
|
|
513
|
+
} catch (err) {
|
|
514
|
+
debug(`sendMessageToSession: error - ${err.message}`);
|
|
515
|
+
return {
|
|
516
|
+
success: false,
|
|
517
|
+
error: err.message,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Find an existing session to reuse for the given directory
|
|
524
|
+
* Returns null if no suitable session found (archived sessions are excluded)
|
|
525
|
+
*
|
|
526
|
+
* @param {string} serverUrl - Server URL
|
|
527
|
+
* @param {string} directory - Working directory to match
|
|
528
|
+
* @param {object} [options] - Options
|
|
529
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
530
|
+
* @returns {Promise<object|null>} Session to reuse, or null
|
|
531
|
+
*/
|
|
532
|
+
export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
533
|
+
// Get sessions for this directory
|
|
534
|
+
const sessions = await listSessions(serverUrl, {
|
|
535
|
+
directory,
|
|
536
|
+
fetch: options.fetch
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (sessions.length === 0) {
|
|
540
|
+
debug(`findReusableSession: no sessions found for ${directory}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Filter out archived sessions
|
|
545
|
+
const activeSessions = sessions.filter(s => !isSessionArchived(s));
|
|
546
|
+
|
|
547
|
+
if (activeSessions.length === 0) {
|
|
548
|
+
debug(`findReusableSession: all ${sessions.length} sessions are archived for ${directory}`);
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
debug(`findReusableSession: found ${activeSessions.length} active sessions for ${directory}`);
|
|
553
|
+
|
|
554
|
+
// Get statuses to prefer idle sessions
|
|
555
|
+
const statuses = await getSessionStatuses(serverUrl, { fetch: options.fetch });
|
|
556
|
+
|
|
557
|
+
// Select the best session
|
|
558
|
+
return selectBestSession(activeSessions, statuses);
|
|
559
|
+
}
|
|
560
|
+
|
|
309
561
|
/**
|
|
310
562
|
* Create a session via the OpenCode HTTP API
|
|
311
563
|
*
|
|
@@ -500,6 +752,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
500
752
|
worktree: worktreeMode,
|
|
501
753
|
// Expand worktree_name template with item fields (e.g., "issue-{number}")
|
|
502
754
|
worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
|
|
755
|
+
// Config flag to control sandbox reuse (default true)
|
|
756
|
+
preferExistingSandbox: config.prefer_existing_sandbox,
|
|
503
757
|
};
|
|
504
758
|
|
|
505
759
|
const worktreeResult = await resolveWorktreeDirectory(
|
|
@@ -513,6 +767,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
513
767
|
|
|
514
768
|
if (worktreeResult.worktreeCreated) {
|
|
515
769
|
debug(`executeAction: created new worktree at ${cwd}`);
|
|
770
|
+
} else if (worktreeResult.worktreeReused) {
|
|
771
|
+
debug(`executeAction: reusing existing sandbox at ${cwd}`);
|
|
516
772
|
} else if (worktreeResult.error) {
|
|
517
773
|
debug(`executeAction: worktree resolution warning - ${worktreeResult.error}`);
|
|
518
774
|
}
|
|
@@ -527,6 +783,34 @@ export async function executeAction(item, config, options = {}) {
|
|
|
527
783
|
? buildSessionName(config.session.name, item)
|
|
528
784
|
: (item.title || `session-${Date.now()}`);
|
|
529
785
|
|
|
786
|
+
// Check if we should try to reuse an existing session
|
|
787
|
+
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
788
|
+
|
|
789
|
+
if (reuseActiveSession && !options.dryRun) {
|
|
790
|
+
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
791
|
+
|
|
792
|
+
if (existingSession) {
|
|
793
|
+
debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
|
|
794
|
+
|
|
795
|
+
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
796
|
+
|
|
797
|
+
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
798
|
+
title: sessionTitle,
|
|
799
|
+
agent: config.agent,
|
|
800
|
+
model: config.model,
|
|
801
|
+
fetch: options.fetch,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
command: reuseCommand,
|
|
806
|
+
success: result.success,
|
|
807
|
+
sessionId: result.sessionId,
|
|
808
|
+
sessionReused: true,
|
|
809
|
+
error: result.error,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
530
814
|
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
531
815
|
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
532
816
|
|
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/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
|
|