opencode-pilot 0.17.1 → 0.18.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/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.1",
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";
@@ -124,7 +124,12 @@ export async function pollOnce(options = {}) {
124
124
  // Fetch items from source
125
125
  if (!skipMcp) {
126
126
  try {
127
- toolProviderConfig = getToolProviderConfig(source.tool.mcp);
127
+ // Get provider config - for MCP sources use source.tool.mcp, for CLI sources detect provider
128
+ let provider = source.tool.mcp;
129
+ if (!provider && Array.isArray(source.tool?.command) && source.tool.command[0] === 'gh') {
130
+ provider = 'github'; // CLI-based GitHub source
131
+ }
132
+ toolProviderConfig = getToolProviderConfig(provider);
128
133
  items = await pollGenericSource(source, { toolProviderConfig });
129
134
  debug(`Fetched ${items.length} items from ${sourceName}`);
130
135
 
@@ -133,6 +138,18 @@ export async function pollOnce(options = {}) {
133
138
  items = await enrichItemsWithComments(items, source);
134
139
  debug(`Enriched ${items.length} items with comments for bot filtering`);
135
140
  }
141
+
142
+ // Enrich items with mergeable status for conflict detection if configured
143
+ if (source.enrich_mergeable) {
144
+ items = await enrichItemsWithMergeable(items, source);
145
+ debug(`Enriched ${items.length} items with mergeable status`);
146
+ }
147
+
148
+ // Compute attention labels if both enrichments are present (for my-prs-attention)
149
+ if (source.enrich_mergeable && source.filter_bot_comments) {
150
+ items = computeAttentionLabels(items, source);
151
+ debug(`Computed attention labels for ${items.length} items`);
152
+ }
136
153
  } catch (err) {
137
154
  console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
138
155
  continue;
package/service/poller.js CHANGED
@@ -12,7 +12,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
12
12
  import fs from "fs";
13
13
  import path from "path";
14
14
  import os from "os";
15
- import { getNestedValue } from "./utils.js";
15
+ import { getNestedValue, hasNonBotFeedback } from "./utils.js";
16
16
 
17
17
  /**
18
18
  * Expand template string with item fields
@@ -501,6 +501,22 @@ export async function fetchGitHubComments(item, options = {}) {
501
501
  }
502
502
  }
503
503
 
504
+ /**
505
+ * Check if a source is a GitHub source (MCP or CLI-based)
506
+ * @param {object} source - Source configuration
507
+ * @returns {boolean} True if this is a GitHub source
508
+ */
509
+ function isGitHubSource(source) {
510
+ // MCP-based GitHub source
511
+ if (source.tool?.mcp === "github") return true;
512
+
513
+ // CLI-based GitHub source (uses gh command)
514
+ const command = source.tool?.command;
515
+ if (Array.isArray(command) && command[0] === "gh") return true;
516
+
517
+ return false;
518
+ }
519
+
504
520
  /**
505
521
  * Enrich items with comments for bot filtering
506
522
  *
@@ -514,7 +530,7 @@ export async function fetchGitHubComments(item, options = {}) {
514
530
  */
515
531
  export async function enrichItemsWithComments(items, source, options = {}) {
516
532
  // Skip if not configured or not a GitHub source
517
- if (!source.filter_bot_comments || source.tool?.mcp !== "github") {
533
+ if (!source.filter_bot_comments || !isGitHubSource(source)) {
518
534
  return items;
519
535
  }
520
536
 
@@ -533,6 +549,111 @@ export async function enrichItemsWithComments(items, source, options = {}) {
533
549
  return enrichedItems;
534
550
  }
535
551
 
552
+ /**
553
+ * Fetch mergeable status for a PR via gh CLI
554
+ *
555
+ * @param {string} owner - Repository owner
556
+ * @param {string} repo - Repository name
557
+ * @param {number} number - PR number
558
+ * @param {number} timeout - Timeout in ms
559
+ * @returns {Promise<string|null>} Mergeable status ("MERGEABLE", "CONFLICTING", "UNKNOWN") or null on error
560
+ */
561
+ async function fetchMergeableStatus(owner, repo, number, timeout) {
562
+ const { exec } = await import('child_process');
563
+ const { promisify } = await import('util');
564
+ const execAsync = promisify(exec);
565
+
566
+ try {
567
+ const { stdout } = await Promise.race([
568
+ execAsync(`gh pr view ${number} -R ${owner}/${repo} --json mergeable --jq .mergeable`),
569
+ createTimeout(timeout, "gh pr view"),
570
+ ]);
571
+
572
+ const status = stdout.trim();
573
+ return status || null;
574
+ } catch (err) {
575
+ console.error(`[poller] Error fetching mergeable status for ${owner}/${repo}#${number}: ${err.message}`);
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Enrich items with mergeable status for conflict detection
582
+ *
583
+ * For items from sources with enrich_mergeable: true, fetches mergeable status
584
+ * via gh CLI and attaches it as _mergeable field for readiness evaluation.
585
+ *
586
+ * @param {Array} items - Items to enrich
587
+ * @param {object} source - Source configuration with optional enrich_mergeable
588
+ * @param {object} [options] - Options
589
+ * @param {number} [options.timeout] - Timeout in ms (default: 30000)
590
+ * @returns {Promise<Array>} Items with _mergeable field added
591
+ */
592
+ export async function enrichItemsWithMergeable(items, source, options = {}) {
593
+ // Skip if not configured
594
+ if (!source.enrich_mergeable) {
595
+ return items;
596
+ }
597
+
598
+ const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
599
+
600
+ // Fetch mergeable status for each item
601
+ const enrichedItems = [];
602
+ for (const item of items) {
603
+ // Extract owner/repo from item
604
+ const fullName = item.repository_full_name || item.repository?.nameWithOwner;
605
+ if (!fullName || !item.number) {
606
+ enrichedItems.push(item);
607
+ continue;
608
+ }
609
+
610
+ const [owner, repo] = fullName.split("/");
611
+ const mergeable = await fetchMergeableStatus(owner, repo, item.number, timeout);
612
+ enrichedItems.push({ ...item, _mergeable: mergeable });
613
+ }
614
+
615
+ return enrichedItems;
616
+ }
617
+
618
+ /**
619
+ * Compute attention label from enriched item conditions
620
+ *
621
+ * Examines _mergeable and _comments fields to determine what needs attention.
622
+ * Sets _attention_label (for session name) and _has_attention (for readiness).
623
+ *
624
+ * @param {Array} items - Items enriched with _mergeable and/or _comments
625
+ * @param {object} source - Source configuration
626
+ * @returns {Array} Items with _attention_label and _has_attention added
627
+ */
628
+ export function computeAttentionLabels(items, source) {
629
+ return items.map(item => {
630
+ const reasons = [];
631
+
632
+ // Check for merge conflicts
633
+ if (item._mergeable === 'CONFLICTING') {
634
+ reasons.push('Conflicts');
635
+ }
636
+
637
+ // Check for human feedback using the shared hasNonBotFeedback utility
638
+ // This properly handles known bots like 'linear' that don't have [bot] suffix
639
+ if (item._comments && item._comments.length > 0) {
640
+ const authorUsername = item.user?.login || item.author?.login;
641
+ if (hasNonBotFeedback(item._comments, authorUsername)) {
642
+ reasons.push('Feedback');
643
+ }
644
+ }
645
+
646
+ // Build label: "Conflicts", "Feedback", or "Conflicts+Feedback"
647
+ const label = reasons.length > 0 ? reasons.join('+') : 'PR';
648
+
649
+ return {
650
+ ...item,
651
+ _attention_label: label,
652
+ _has_attention: reasons.length > 0,
653
+ };
654
+ });
655
+ }
656
+
536
657
  /**
537
658
  * Create a poller instance with state tracking
538
659
  *
@@ -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,136 @@ 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
+
1017
+ test('ignores known bots without [bot] suffix (e.g., linear)', async () => {
1018
+ const { computeAttentionLabels } = await import('../../service/poller.js');
1019
+
1020
+ const items = [{
1021
+ number: 123,
1022
+ title: 'Test PR',
1023
+ user: { login: 'author' },
1024
+ _mergeable: 'MERGEABLE',
1025
+ _comments: [
1026
+ // Linear bot posts linkback comments without [bot] suffix
1027
+ { user: { login: 'linear', type: 'User' }, body: '<!-- linear-linkback -->' }
1028
+ ]
1029
+ }];
1030
+
1031
+ const result = computeAttentionLabels(items, {});
1032
+
1033
+ assert.strictEqual(result[0]._attention_label, 'PR');
1034
+ assert.strictEqual(result[0]._has_attention, false);
1035
+ });
1036
+ });
905
1037
  });
@@ -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 () => {