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.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # Example config.yaml for opencode-pilot
2
- # Copy to ~/.config/opencode-pilot/config.yaml
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.18.3",
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",
@@ -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 BOTH types of comments using gh CLI:
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 both endpoints)
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 both PR review comments AND issue comments in parallel via gh CLI
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 comments from both sources
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/issue has non-bot feedback (comments from humans other than the author)
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 for human feedback.
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
- * Also skips approval-only reviews (APPROVED state with no body text) since
79
- * approvals don't require action from the author.
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 non-bot, non-author, actionable comment
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
- // Skip if it's the author themselves
104
- if (authorLower && username?.toLowerCase() === authorLower) continue;
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 a non-bot, non-author, actionable comment
170
+ // Found actionable feedback
110
171
  return true;
111
172
  }
112
173