opencode-pilot 0.17.1 → 0.18.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 CHANGED
@@ -65,9 +65,12 @@ Three ways to configure sources, from simplest to most flexible:
65
65
 
66
66
  - `github/my-issues` - Issues assigned to me
67
67
  - `github/review-requests` - PRs needing my review
68
- - `github/my-prs-feedback` - My PRs with change requests
68
+ - `github/my-prs-feedback` - My PRs with comments/feedback (simple trigger)
69
+ - `github/my-prs-attention` - My PRs needing attention (conflicts OR feedback, with dynamic labeling)
69
70
  - `linear/my-issues` - Linear tickets (requires `teamId`, `assigneeId`)
70
71
 
72
+ **Tip:** Use `my-prs-attention` instead of `my-prs-feedback` if you also want to detect merge conflicts. The session name will indicate which condition(s) triggered it: "Conflicts: {title}", "Feedback: {title}", or "Conflicts+Feedback: {title}".
73
+
71
74
  ### Prompt Templates
72
75
 
73
76
  Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
@@ -27,11 +27,16 @@ sources:
27
27
 
28
28
  - preset: github/review-requests
29
29
 
30
- - preset: github/my-prs-feedback
30
+ # PRs needing attention (conflicts OR feedback) - recommended over my-prs-feedback
31
+ # Session names dynamically indicate the condition: "Conflicts: ...", "Feedback: ...", or "Conflicts+Feedback: ..."
32
+ - preset: github/my-prs-attention
31
33
  repos:
32
34
  - myorg/backend
33
35
  - myorg/frontend
34
36
 
37
+ # Alternative: Simple feedback-only trigger (use my-prs-attention for conflicts too)
38
+ # - preset: github/my-prs-feedback
39
+
35
40
  # Linear (requires teamId and assigneeId)
36
41
  - preset: linear/my-issues
37
42
  args:
@@ -87,4 +92,4 @@ sources:
87
92
  # ttl_days: 30
88
93
 
89
94
  # Available presets: github/my-issues, github/review-requests,
90
- # github/my-prs-feedback, linear/my-issues
95
+ # github/my-prs-feedback, github/my-prs-attention, linear/my-issues
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays, getStartupDelay } from "./repo-config.js";
13
- import { createPoller, pollGenericSource, enrichItemsWithComments } from "./poller.js";
13
+ import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, computeAttentionLabels } 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";
@@ -133,6 +133,18 @@ export async function pollOnce(options = {}) {
133
133
  items = await enrichItemsWithComments(items, source);
134
134
  debug(`Enriched ${items.length} items with comments for bot filtering`);
135
135
  }
136
+
137
+ // Enrich items with mergeable status for conflict detection if configured
138
+ if (source.enrich_mergeable) {
139
+ items = await enrichItemsWithMergeable(items, source);
140
+ debug(`Enriched ${items.length} items with mergeable status`);
141
+ }
142
+
143
+ // Compute attention labels if both enrichments are present (for my-prs-attention)
144
+ if (source.enrich_mergeable && source.filter_bot_comments) {
145
+ items = computeAttentionLabels(items, source);
146
+ debug(`Computed attention labels for ${items.length} items`);
147
+ }
136
148
  } catch (err) {
137
149
  console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
138
150
  continue;
package/service/poller.js CHANGED
@@ -533,6 +533,116 @@ export async function enrichItemsWithComments(items, source, options = {}) {
533
533
  return enrichedItems;
534
534
  }
535
535
 
536
+ /**
537
+ * Fetch mergeable status for a PR via gh CLI
538
+ *
539
+ * @param {string} owner - Repository owner
540
+ * @param {string} repo - Repository name
541
+ * @param {number} number - PR number
542
+ * @param {number} timeout - Timeout in ms
543
+ * @returns {Promise<string|null>} Mergeable status ("MERGEABLE", "CONFLICTING", "UNKNOWN") or null on error
544
+ */
545
+ async function fetchMergeableStatus(owner, repo, number, timeout) {
546
+ const { exec } = await import('child_process');
547
+ const { promisify } = await import('util');
548
+ const execAsync = promisify(exec);
549
+
550
+ try {
551
+ const { stdout } = await Promise.race([
552
+ execAsync(`gh pr view ${number} -R ${owner}/${repo} --json mergeable --jq .mergeable`),
553
+ createTimeout(timeout, "gh pr view"),
554
+ ]);
555
+
556
+ const status = stdout.trim();
557
+ return status || null;
558
+ } catch (err) {
559
+ console.error(`[poller] Error fetching mergeable status for ${owner}/${repo}#${number}: ${err.message}`);
560
+ return null;
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Enrich items with mergeable status for conflict detection
566
+ *
567
+ * For items from sources with enrich_mergeable: true, fetches mergeable status
568
+ * via gh CLI and attaches it as _mergeable field for readiness evaluation.
569
+ *
570
+ * @param {Array} items - Items to enrich
571
+ * @param {object} source - Source configuration with optional enrich_mergeable
572
+ * @param {object} [options] - Options
573
+ * @param {number} [options.timeout] - Timeout in ms (default: 30000)
574
+ * @returns {Promise<Array>} Items with _mergeable field added
575
+ */
576
+ export async function enrichItemsWithMergeable(items, source, options = {}) {
577
+ // Skip if not configured
578
+ if (!source.enrich_mergeable) {
579
+ return items;
580
+ }
581
+
582
+ const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
583
+
584
+ // Fetch mergeable status for each item
585
+ const enrichedItems = [];
586
+ for (const item of items) {
587
+ // Extract owner/repo from item
588
+ const fullName = item.repository_full_name || item.repository?.nameWithOwner;
589
+ if (!fullName || !item.number) {
590
+ enrichedItems.push(item);
591
+ continue;
592
+ }
593
+
594
+ const [owner, repo] = fullName.split("/");
595
+ const mergeable = await fetchMergeableStatus(owner, repo, item.number, timeout);
596
+ enrichedItems.push({ ...item, _mergeable: mergeable });
597
+ }
598
+
599
+ return enrichedItems;
600
+ }
601
+
602
+ /**
603
+ * Compute attention label from enriched item conditions
604
+ *
605
+ * Examines _mergeable and _comments fields to determine what needs attention.
606
+ * Sets _attention_label (for session name) and _has_attention (for readiness).
607
+ *
608
+ * @param {Array} items - Items enriched with _mergeable and/or _comments
609
+ * @param {object} source - Source configuration
610
+ * @returns {Array} Items with _attention_label and _has_attention added
611
+ */
612
+ export function computeAttentionLabels(items, source) {
613
+ return items.map(item => {
614
+ const reasons = [];
615
+
616
+ // Check for merge conflicts
617
+ if (item._mergeable === 'CONFLICTING') {
618
+ reasons.push('Conflicts');
619
+ }
620
+
621
+ // Check for human feedback (non-bot, non-author comments)
622
+ if (item._comments && item._comments.length > 0) {
623
+ const authorUsername = item.user?.login || item.author?.login;
624
+ const hasHumanFeedback = item._comments.some(comment => {
625
+ const commenter = comment.user?.login || comment.author?.login;
626
+ const isBot = commenter?.includes('[bot]') || comment.user?.type === 'Bot';
627
+ const isAuthor = commenter === authorUsername;
628
+ return !isBot && !isAuthor;
629
+ });
630
+ if (hasHumanFeedback) {
631
+ reasons.push('Feedback');
632
+ }
633
+ }
634
+
635
+ // Build label: "Conflicts", "Feedback", or "Conflicts+Feedback"
636
+ const label = reasons.length > 0 ? reasons.join('+') : 'PR';
637
+
638
+ return {
639
+ ...item,
640
+ _attention_label: label,
641
+ _has_attention: reasons.length > 0,
642
+ };
643
+ });
644
+ }
645
+
536
646
  /**
537
647
  * Create a poller instance with state tracking
538
648
  *
@@ -41,7 +41,7 @@ my-prs-feedback:
41
41
  name: my-prs-feedback
42
42
  tool:
43
43
  # comments:>0 filter ensures only PRs with feedback are returned
44
- command: ["gh", "search", "prs", "--author=@me", "--state=open", "comments:>0", "--json", "number,title,url,repository,state,body,updatedAt"]
44
+ command: ["gh", "search", "prs", "--author=@me", "--state=open", "comments:>0", "--json", "number,title,url,repository,state,body,updatedAt,commentsCount"]
45
45
  item:
46
46
  id: "{url}"
47
47
  repo: "{repository.nameWithOwner}"
@@ -53,3 +53,26 @@ my-prs-feedback:
53
53
  reprocess_on:
54
54
  - state
55
55
  - updatedAt
56
+
57
+ my-prs-attention:
58
+ name: my-prs-attention
59
+ tool:
60
+ # Get all open PRs authored by me - we filter by conditions after enrichment
61
+ command: ["gh", "search", "prs", "--author=@me", "--state=open", "--json", "number,title,url,repository,state,body,updatedAt,commentsCount"]
62
+ item:
63
+ id: "{url}"
64
+ repo: "{repository.nameWithOwner}"
65
+ prompt: review-feedback
66
+ session:
67
+ # Dynamic name showing which conditions triggered (set by enrichment)
68
+ name: "{_attention_label}: {title}"
69
+ # Enrich with both mergeable status and comments to detect all attention conditions
70
+ enrich_mergeable: true
71
+ filter_bot_comments: true
72
+ readiness:
73
+ # Require at least one attention condition (conflicts or human feedback)
74
+ require_attention: true
75
+ # Reprocess when PR is updated
76
+ reprocess_on:
77
+ - state
78
+ - updatedAt
@@ -213,6 +213,77 @@ export function checkBotComments(item, config) {
213
213
  };
214
214
  }
215
215
 
216
+ /**
217
+ * Check if a PR has merge conflicts
218
+ *
219
+ * This check is only applied when the item has been enriched with `_mergeable`
220
+ * (the mergeable status from GitHub: "MERGEABLE", "CONFLICTING", or "UNKNOWN").
221
+ * Items without `_mergeable` are considered ready (check is skipped).
222
+ *
223
+ * Used by the my-prs-conflicts source to filter to only PRs with conflicts.
224
+ *
225
+ * @param {object} item - Item with optional _mergeable field
226
+ * @param {object} config - Repo config with optional readiness.require_conflicts
227
+ * @returns {object} { ready: boolean, reason?: string }
228
+ */
229
+ export function checkMergeable(item, config) {
230
+ const readinessConfig = config.readiness || {};
231
+
232
+ // Skip check if no _mergeable field (item not enriched)
233
+ if (!item._mergeable) {
234
+ return { ready: true };
235
+ }
236
+
237
+ // Check if we require conflicts (for conflict-detection sources)
238
+ if (readinessConfig.require_conflicts) {
239
+ if (item._mergeable === "CONFLICTING") {
240
+ return { ready: true };
241
+ }
242
+ return {
243
+ ready: false,
244
+ reason: `PR is ${item._mergeable}, not CONFLICTING`,
245
+ };
246
+ }
247
+
248
+ // Default: allow any mergeable status
249
+ return { ready: true };
250
+ }
251
+
252
+ /**
253
+ * Check if a PR needs attention (has conflicts OR human feedback)
254
+ *
255
+ * This check uses the _has_attention field computed by computeAttentionLabels().
256
+ * Items without _has_attention are considered ready (check is skipped).
257
+ *
258
+ * Used by the my-prs-attention source to filter to PRs needing action.
259
+ *
260
+ * @param {object} item - Item with optional _has_attention field
261
+ * @param {object} config - Repo config with optional readiness.require_attention
262
+ * @returns {object} { ready: boolean, reason?: string }
263
+ */
264
+ export function checkAttention(item, config) {
265
+ const readinessConfig = config.readiness || {};
266
+
267
+ // Skip check if require_attention not configured
268
+ if (!readinessConfig.require_attention) {
269
+ return { ready: true };
270
+ }
271
+
272
+ // Skip check if _has_attention not computed (item not enriched)
273
+ if (item._has_attention === undefined) {
274
+ return { ready: true };
275
+ }
276
+
277
+ if (item._has_attention) {
278
+ return { ready: true };
279
+ }
280
+
281
+ return {
282
+ ready: false,
283
+ reason: "PR has no conflicts and no human feedback - no attention needed",
284
+ };
285
+ }
286
+
216
287
  /**
217
288
  * Calculate priority score for an issue
218
289
  * @param {object} issue - Issue with labels and created_at
@@ -286,6 +357,26 @@ export function evaluateReadiness(issue, config) {
286
357
  };
287
358
  }
288
359
 
360
+ // Check mergeable status (for PRs enriched with _mergeable)
361
+ const mergeableResult = checkMergeable(issue, config);
362
+ if (!mergeableResult.ready) {
363
+ return {
364
+ ready: false,
365
+ reason: mergeableResult.reason,
366
+ priority: 0,
367
+ };
368
+ }
369
+
370
+ // Check attention status (for PRs needing conflicts OR feedback)
371
+ const attentionResult = checkAttention(issue, config);
372
+ if (!attentionResult.ready) {
373
+ return {
374
+ ready: false,
375
+ reason: attentionResult.reason,
376
+ priority: 0,
377
+ };
378
+ }
379
+
289
380
  // Check required field values
290
381
  const fieldsResult = checkFields(issue, config);
291
382
  if (!fieldsResult.ready) {
@@ -902,4 +902,116 @@ describe('poller.js', () => {
902
902
  assert.strictEqual(transformed[0].title, 'First');
903
903
  });
904
904
  });
905
+
906
+ describe('computeAttentionLabels', () => {
907
+ test('labels PR with conflicts only', async () => {
908
+ const { computeAttentionLabels } = await import('../../service/poller.js');
909
+
910
+ const items = [{
911
+ number: 123,
912
+ title: 'Test PR',
913
+ _mergeable: 'CONFLICTING',
914
+ _comments: []
915
+ }];
916
+
917
+ const result = computeAttentionLabels(items, {});
918
+
919
+ assert.strictEqual(result[0]._attention_label, 'Conflicts');
920
+ assert.strictEqual(result[0]._has_attention, true);
921
+ });
922
+
923
+ test('labels PR with human feedback only', async () => {
924
+ const { computeAttentionLabels } = await import('../../service/poller.js');
925
+
926
+ const items = [{
927
+ number: 123,
928
+ title: 'Test PR',
929
+ user: { login: 'author' },
930
+ _mergeable: 'MERGEABLE',
931
+ _comments: [
932
+ { user: { login: 'reviewer', type: 'User' }, body: 'Please fix' }
933
+ ]
934
+ }];
935
+
936
+ const result = computeAttentionLabels(items, {});
937
+
938
+ assert.strictEqual(result[0]._attention_label, 'Feedback');
939
+ assert.strictEqual(result[0]._has_attention, true);
940
+ });
941
+
942
+ test('labels PR with both conflicts and feedback', async () => {
943
+ const { computeAttentionLabels } = await import('../../service/poller.js');
944
+
945
+ const items = [{
946
+ number: 123,
947
+ title: 'Test PR',
948
+ user: { login: 'author' },
949
+ _mergeable: 'CONFLICTING',
950
+ _comments: [
951
+ { user: { login: 'reviewer', type: 'User' }, body: 'Please fix' }
952
+ ]
953
+ }];
954
+
955
+ const result = computeAttentionLabels(items, {});
956
+
957
+ assert.strictEqual(result[0]._attention_label, 'Conflicts+Feedback');
958
+ assert.strictEqual(result[0]._has_attention, true);
959
+ });
960
+
961
+ test('labels PR with no attention conditions', async () => {
962
+ const { computeAttentionLabels } = await import('../../service/poller.js');
963
+
964
+ const items = [{
965
+ number: 123,
966
+ title: 'Test PR',
967
+ user: { login: 'author' },
968
+ _mergeable: 'MERGEABLE',
969
+ _comments: []
970
+ }];
971
+
972
+ const result = computeAttentionLabels(items, {});
973
+
974
+ assert.strictEqual(result[0]._attention_label, 'PR');
975
+ assert.strictEqual(result[0]._has_attention, false);
976
+ });
977
+
978
+ test('ignores bot comments when computing feedback', async () => {
979
+ const { computeAttentionLabels } = await import('../../service/poller.js');
980
+
981
+ const items = [{
982
+ number: 123,
983
+ title: 'Test PR',
984
+ user: { login: 'author' },
985
+ _mergeable: 'MERGEABLE',
986
+ _comments: [
987
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
988
+ { user: { login: 'codecov[bot]', type: 'Bot' }, body: 'Coverage report' }
989
+ ]
990
+ }];
991
+
992
+ const result = computeAttentionLabels(items, {});
993
+
994
+ assert.strictEqual(result[0]._attention_label, 'PR');
995
+ assert.strictEqual(result[0]._has_attention, false);
996
+ });
997
+
998
+ test('ignores author comments when computing feedback', async () => {
999
+ const { computeAttentionLabels } = await import('../../service/poller.js');
1000
+
1001
+ const items = [{
1002
+ number: 123,
1003
+ title: 'Test PR',
1004
+ user: { login: 'author' },
1005
+ _mergeable: 'MERGEABLE',
1006
+ _comments: [
1007
+ { user: { login: 'author', type: 'User' }, body: 'Added screenshots' }
1008
+ ]
1009
+ }];
1010
+
1011
+ const result = computeAttentionLabels(items, {});
1012
+
1013
+ assert.strictEqual(result[0]._attention_label, 'PR');
1014
+ assert.strictEqual(result[0]._has_attention, false);
1015
+ });
1016
+ });
905
1017
  });
@@ -387,4 +387,293 @@ describe('readiness.js', () => {
387
387
  assert.strictEqual(result.ready, true);
388
388
  });
389
389
  });
390
+
391
+ describe('checkMergeable', () => {
392
+ test('returns ready when no _mergeable field (skip check)', async () => {
393
+ const { checkMergeable } = await import('../../service/readiness.js');
394
+
395
+ const pr = {
396
+ number: 123,
397
+ title: 'Test PR'
398
+ };
399
+ const config = {};
400
+
401
+ const result = checkMergeable(pr, config);
402
+
403
+ assert.strictEqual(result.ready, true);
404
+ });
405
+
406
+ test('returns ready when PR has conflicts and require_conflicts is true', async () => {
407
+ const { checkMergeable } = await import('../../service/readiness.js');
408
+
409
+ const pr = {
410
+ number: 123,
411
+ title: 'Test PR',
412
+ _mergeable: 'CONFLICTING'
413
+ };
414
+ const config = {
415
+ readiness: {
416
+ require_conflicts: true
417
+ }
418
+ };
419
+
420
+ const result = checkMergeable(pr, config);
421
+
422
+ assert.strictEqual(result.ready, true);
423
+ });
424
+
425
+ test('returns not ready when PR is mergeable and require_conflicts is true', async () => {
426
+ const { checkMergeable } = await import('../../service/readiness.js');
427
+
428
+ const pr = {
429
+ number: 123,
430
+ title: 'Test PR',
431
+ _mergeable: 'MERGEABLE'
432
+ };
433
+ const config = {
434
+ readiness: {
435
+ require_conflicts: true
436
+ }
437
+ };
438
+
439
+ const result = checkMergeable(pr, config);
440
+
441
+ assert.strictEqual(result.ready, false);
442
+ assert.ok(result.reason.includes('MERGEABLE'));
443
+ });
444
+
445
+ test('returns not ready when PR status is UNKNOWN and require_conflicts is true', async () => {
446
+ const { checkMergeable } = await import('../../service/readiness.js');
447
+
448
+ const pr = {
449
+ number: 123,
450
+ title: 'Test PR',
451
+ _mergeable: 'UNKNOWN'
452
+ };
453
+ const config = {
454
+ readiness: {
455
+ require_conflicts: true
456
+ }
457
+ };
458
+
459
+ const result = checkMergeable(pr, config);
460
+
461
+ assert.strictEqual(result.ready, false);
462
+ assert.ok(result.reason.includes('UNKNOWN'));
463
+ });
464
+
465
+ test('returns ready for any mergeable status when require_conflicts is not set', async () => {
466
+ const { checkMergeable } = await import('../../service/readiness.js');
467
+
468
+ const pr = {
469
+ number: 123,
470
+ title: 'Test PR',
471
+ _mergeable: 'CONFLICTING'
472
+ };
473
+ const config = {};
474
+
475
+ const result = checkMergeable(pr, config);
476
+
477
+ assert.strictEqual(result.ready, true);
478
+ });
479
+ });
480
+
481
+ describe('evaluateReadiness with mergeable', () => {
482
+ test('checks mergeable when _mergeable is present with require_conflicts', async () => {
483
+ const { evaluateReadiness } = await import('../../service/readiness.js');
484
+
485
+ const pr = {
486
+ number: 123,
487
+ title: 'Test PR',
488
+ _mergeable: 'MERGEABLE'
489
+ };
490
+ const config = {
491
+ readiness: {
492
+ require_conflicts: true
493
+ }
494
+ };
495
+
496
+ const result = evaluateReadiness(pr, config);
497
+
498
+ assert.strictEqual(result.ready, false);
499
+ assert.ok(result.reason.includes('MERGEABLE'));
500
+ });
501
+
502
+ test('passes when PR has conflicts and require_conflicts is configured', async () => {
503
+ const { evaluateReadiness } = await import('../../service/readiness.js');
504
+
505
+ const pr = {
506
+ number: 123,
507
+ title: 'Test PR',
508
+ _mergeable: 'CONFLICTING'
509
+ };
510
+ const config = {
511
+ readiness: {
512
+ require_conflicts: true
513
+ }
514
+ };
515
+
516
+ const result = evaluateReadiness(pr, config);
517
+
518
+ assert.strictEqual(result.ready, true);
519
+ });
520
+ });
521
+
522
+ describe('checkAttention', () => {
523
+ test('returns ready when require_attention is not configured', async () => {
524
+ const { checkAttention } = await import('../../service/readiness.js');
525
+
526
+ const pr = {
527
+ number: 123,
528
+ title: 'Test PR',
529
+ _has_attention: false
530
+ };
531
+ const config = {};
532
+
533
+ const result = checkAttention(pr, config);
534
+
535
+ assert.strictEqual(result.ready, true);
536
+ });
537
+
538
+ test('returns ready when _has_attention is not computed', async () => {
539
+ const { checkAttention } = await import('../../service/readiness.js');
540
+
541
+ const pr = {
542
+ number: 123,
543
+ title: 'Test PR'
544
+ };
545
+ const config = {
546
+ readiness: {
547
+ require_attention: true
548
+ }
549
+ };
550
+
551
+ const result = checkAttention(pr, config);
552
+
553
+ assert.strictEqual(result.ready, true);
554
+ });
555
+
556
+ test('returns ready when PR has attention (conflicts)', async () => {
557
+ const { checkAttention } = await import('../../service/readiness.js');
558
+
559
+ const pr = {
560
+ number: 123,
561
+ title: 'Test PR',
562
+ _has_attention: true,
563
+ _attention_label: 'Conflicts'
564
+ };
565
+ const config = {
566
+ readiness: {
567
+ require_attention: true
568
+ }
569
+ };
570
+
571
+ const result = checkAttention(pr, config);
572
+
573
+ assert.strictEqual(result.ready, true);
574
+ });
575
+
576
+ test('returns ready when PR has attention (feedback)', async () => {
577
+ const { checkAttention } = await import('../../service/readiness.js');
578
+
579
+ const pr = {
580
+ number: 123,
581
+ title: 'Test PR',
582
+ _has_attention: true,
583
+ _attention_label: 'Feedback'
584
+ };
585
+ const config = {
586
+ readiness: {
587
+ require_attention: true
588
+ }
589
+ };
590
+
591
+ const result = checkAttention(pr, config);
592
+
593
+ assert.strictEqual(result.ready, true);
594
+ });
595
+
596
+ test('returns ready when PR has both conditions', async () => {
597
+ const { checkAttention } = await import('../../service/readiness.js');
598
+
599
+ const pr = {
600
+ number: 123,
601
+ title: 'Test PR',
602
+ _has_attention: true,
603
+ _attention_label: 'Conflicts+Feedback'
604
+ };
605
+ const config = {
606
+ readiness: {
607
+ require_attention: true
608
+ }
609
+ };
610
+
611
+ const result = checkAttention(pr, config);
612
+
613
+ assert.strictEqual(result.ready, true);
614
+ });
615
+
616
+ test('returns not ready when PR has no attention conditions', async () => {
617
+ const { checkAttention } = await import('../../service/readiness.js');
618
+
619
+ const pr = {
620
+ number: 123,
621
+ title: 'Test PR',
622
+ _has_attention: false,
623
+ _attention_label: 'PR'
624
+ };
625
+ const config = {
626
+ readiness: {
627
+ require_attention: true
628
+ }
629
+ };
630
+
631
+ const result = checkAttention(pr, config);
632
+
633
+ assert.strictEqual(result.ready, false);
634
+ assert.ok(result.reason.includes('no conflicts'));
635
+ });
636
+ });
637
+
638
+ describe('evaluateReadiness with attention', () => {
639
+ test('checks attention when require_attention is configured', async () => {
640
+ const { evaluateReadiness } = await import('../../service/readiness.js');
641
+
642
+ const pr = {
643
+ number: 123,
644
+ title: 'Test PR',
645
+ _has_attention: false
646
+ };
647
+ const config = {
648
+ readiness: {
649
+ require_attention: true
650
+ }
651
+ };
652
+
653
+ const result = evaluateReadiness(pr, config);
654
+
655
+ assert.strictEqual(result.ready, false);
656
+ assert.ok(result.reason.includes('no attention needed'));
657
+ });
658
+
659
+ test('passes when PR has attention', async () => {
660
+ const { evaluateReadiness } = await import('../../service/readiness.js');
661
+
662
+ const pr = {
663
+ number: 123,
664
+ title: 'Test PR',
665
+ _has_attention: true,
666
+ _attention_label: 'Conflicts'
667
+ };
668
+ const config = {
669
+ readiness: {
670
+ require_attention: true
671
+ }
672
+ };
673
+
674
+ const result = evaluateReadiness(pr, config);
675
+
676
+ assert.strictEqual(result.ready, true);
677
+ });
678
+ });
390
679
  });
@@ -697,6 +697,28 @@ sources:
697
697
  assert.deepStrictEqual(sources[0].reprocess_on, ['state', 'updatedAt']);
698
698
  });
699
699
 
700
+ test('expands github/my-prs-attention preset', async () => {
701
+ writeFileSync(configPath, `
702
+ sources:
703
+ - preset: github/my-prs-attention
704
+ `);
705
+
706
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
707
+ loadRepoConfig(configPath);
708
+ const sources = getSources();
709
+
710
+ assert.strictEqual(sources[0].name, 'my-prs-attention');
711
+ // GitHub presets now use gh CLI instead of MCP
712
+ assert.ok(sources[0].tool.command.includes('--author=@me'), 'command should include author filter');
713
+ // This preset enables both mergeable and comments enrichment
714
+ assert.strictEqual(sources[0].enrich_mergeable, true);
715
+ assert.strictEqual(sources[0].filter_bot_comments, true);
716
+ // This preset requires attention (conflicts OR human feedback)
717
+ assert.strictEqual(sources[0].readiness.require_attention, true);
718
+ // Session name uses dynamic attention label
719
+ assert.ok(sources[0].session.name.includes('_attention_label'), 'session name should use dynamic label');
720
+ });
721
+
700
722
  test('expands linear/my-issues preset with required args', async () => {
701
723
  writeFileSync(configPath, `
702
724
  sources:
@@ -753,6 +775,7 @@ sources:
753
775
  - preset: github/my-issues
754
776
  - preset: github/review-requests
755
777
  - preset: github/my-prs-feedback
778
+ - preset: github/my-prs-attention
756
779
  `);
757
780
 
758
781
  const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
@@ -838,6 +861,7 @@ sources:
838
861
  - preset: github/my-issues
839
862
  - preset: github/review-requests
840
863
  - preset: github/my-prs-feedback
864
+ - preset: github/my-prs-attention
841
865
  `);
842
866
 
843
867
  const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
@@ -852,6 +876,9 @@ sources:
852
876
 
853
877
  // my-prs-feedback: "Feedback: {title}"
854
878
  assert.strictEqual(sources[2].session.name, 'Feedback: {title}', 'my-prs-feedback should prefix with Feedback:');
879
+
880
+ // my-prs-attention: "{_attention_label}: {title}" (dynamic based on detected conditions)
881
+ assert.ok(sources[3].session.name.includes('_attention_label'), 'my-prs-attention should use dynamic label');
855
882
  });
856
883
 
857
884
  test('linear preset includes session name', async () => {