opencode-pilot 0.20.2 → 0.20.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -845,7 +845,15 @@ export async function executeAction(item, config, options = {}) {
845
845
  // This allows creating sessions in isolated worktrees instead of the main project
846
846
  let worktreeMode = config.worktree;
847
847
 
848
+ // If worktree_name is configured, enable worktree mode (explicit configuration)
849
+ // This allows presets to specify worktree isolation without requiring existing sandboxes
850
+ if (!worktreeMode && config.worktree_name) {
851
+ debug(`executeAction: worktree_name configured, enabling worktree mode`);
852
+ worktreeMode = 'new';
853
+ }
854
+
848
855
  // Auto-detect worktree support: check if the project has sandboxes
856
+ // This is a fallback for when worktree isn't explicitly configured
849
857
  if (!worktreeMode) {
850
858
  // Look up project info for this specific directory (not just /project/current)
851
859
  const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
@@ -50,6 +50,7 @@ export function buildActionConfigFromSource(source, repoConfig) {
50
50
  ...(source.agent && { agent: source.agent }),
51
51
  ...(source.model && { model: source.model }),
52
52
  ...(source.working_dir && { working_dir: source.working_dir }),
53
+ ...(source.worktree_name && { worktree_name: source.worktree_name }),
53
54
  };
54
55
  }
55
56
 
@@ -24,6 +24,7 @@ my-issues:
24
24
  item:
25
25
  id: "{url}"
26
26
  repo: "{repository.nameWithOwner}"
27
+ worktree_name: "issue-{number}"
27
28
  session:
28
29
  name: "{title}"
29
30
 
@@ -35,6 +36,7 @@ review-requests:
35
36
  id: "{url}"
36
37
  repo: "{repository.nameWithOwner}"
37
38
  prompt: review
39
+ worktree_name: "pr-{number}"
38
40
  session:
39
41
  name: "Review: {title}"
40
42
 
@@ -47,6 +49,7 @@ my-prs-attention:
47
49
  id: "{url}"
48
50
  repo: "{repository.nameWithOwner}"
49
51
  prompt: review-feedback
52
+ worktree_name: "pr-{number}"
50
53
  session:
51
54
  # Dynamic name showing which conditions triggered (set by enrichment)
52
55
  name: "{_attention_label}: {title}"
@@ -56,7 +59,7 @@ my-prs-attention:
56
59
  readiness:
57
60
  # Require at least one attention condition (conflicts or human feedback)
58
61
  require_attention: true
59
- # Reprocess when PR is updated
62
+ # Only reprocess when state changes (e.g., reopened)
63
+ # Note: updatedAt is NOT included - CI status changes would trigger reprocessing
60
64
  reprocess_on:
61
65
  - state
62
- - updatedAt
@@ -21,5 +21,6 @@ my-issues:
21
21
  # teamId and assigneeId are required - user must provide
22
22
  item:
23
23
  id: "linear:{id}"
24
+ worktree_name: "{number}"
24
25
  session:
25
26
  name: "{title}"
@@ -348,13 +348,18 @@ export function evaluateReadiness(issue, config) {
348
348
  }
349
349
 
350
350
  // Check bot comments (for PRs enriched with _comments)
351
- const botResult = checkBotComments(issue, config);
352
- if (!botResult.ready) {
353
- return {
354
- ready: false,
355
- reason: botResult.reason,
356
- priority: 0,
357
- };
351
+ // Skip this check when require_attention is set, because checkAttention
352
+ // handles the combined logic (conflicts OR feedback) via _has_attention
353
+ const readinessConfig = config.readiness || {};
354
+ if (!readinessConfig.require_attention) {
355
+ const botResult = checkBotComments(issue, config);
356
+ if (!botResult.ready) {
357
+ return {
358
+ ready: false,
359
+ reason: botResult.reason,
360
+ priority: 0,
361
+ };
362
+ }
358
363
  }
359
364
 
360
365
  // Check mergeable status (for PRs enriched with _mergeable)
@@ -21,6 +21,14 @@ import {
21
21
  resolveWorktreeDirectory,
22
22
  } from "../../service/worktree.js";
23
23
 
24
+ import {
25
+ computeAttentionLabels,
26
+ } from "../../service/poller.js";
27
+
28
+ import {
29
+ evaluateReadiness,
30
+ } from "../../service/readiness.js";
31
+
24
32
  /**
25
33
  * Create a mock OpenCode server for testing
26
34
  */
@@ -345,3 +353,217 @@ describe("integration: sandbox reuse", () => {
345
353
  assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory when looking up named worktree");
346
354
  });
347
355
  });
356
+
357
+ describe("integration: PR attention detection", () => {
358
+ /**
359
+ * These tests verify the full flow of PR attention detection:
360
+ * 1. PRs with merge conflicts should be detected as needing attention
361
+ * 2. PRs with human feedback should be detected as needing attention
362
+ * 3. PRs with only bot comments but conflicts should still be ready (require_attention mode)
363
+ * 4. computeAttentionLabels + evaluateReadiness work together correctly
364
+ */
365
+
366
+ it("PR with conflicts and only bot comments is ready when require_attention is set", () => {
367
+ // This is the key scenario that was broken: PR has merge conflicts but no human feedback
368
+ // With require_attention, it should be ready because conflicts count as "attention needed"
369
+ const items = [{
370
+ number: 123,
371
+ title: "Test PR",
372
+ user: { login: "author" },
373
+ _mergeable: "CONFLICTING",
374
+ _comments: [
375
+ { user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
376
+ { user: { login: "codecov[bot]", type: "Bot" }, body: "Coverage report" },
377
+ ],
378
+ }];
379
+
380
+ // Step 1: Compute attention labels (happens in poll-service)
381
+ const labeled = computeAttentionLabels(items, {});
382
+
383
+ assert.strictEqual(labeled[0]._attention_label, "Conflicts");
384
+ assert.strictEqual(labeled[0]._has_attention, true);
385
+
386
+ // Step 2: Evaluate readiness with require_attention config
387
+ const config = {
388
+ readiness: {
389
+ require_attention: true,
390
+ },
391
+ };
392
+ const result = evaluateReadiness(labeled[0], config);
393
+
394
+ assert.strictEqual(result.ready, true, "PR with conflicts should be ready even with only bot comments");
395
+ });
396
+
397
+ it("PR with human feedback is ready when require_attention is set", () => {
398
+ const items = [{
399
+ number: 456,
400
+ title: "Another PR",
401
+ user: { login: "author" },
402
+ _mergeable: "MERGEABLE",
403
+ _comments: [
404
+ { user: { login: "reviewer", type: "User" }, body: "Please fix the tests" },
405
+ ],
406
+ }];
407
+
408
+ const labeled = computeAttentionLabels(items, {});
409
+
410
+ assert.strictEqual(labeled[0]._attention_label, "Feedback");
411
+ assert.strictEqual(labeled[0]._has_attention, true);
412
+
413
+ const config = {
414
+ readiness: {
415
+ require_attention: true,
416
+ },
417
+ };
418
+ const result = evaluateReadiness(labeled[0], config);
419
+
420
+ assert.strictEqual(result.ready, true, "PR with human feedback should be ready");
421
+ });
422
+
423
+ it("PR with both conflicts and feedback shows combined label", () => {
424
+ const items = [{
425
+ number: 789,
426
+ title: "Complex PR",
427
+ user: { login: "author" },
428
+ _mergeable: "CONFLICTING",
429
+ _comments: [
430
+ { user: { login: "reviewer", type: "User" }, body: "Needs changes" },
431
+ ],
432
+ }];
433
+
434
+ const labeled = computeAttentionLabels(items, {});
435
+
436
+ assert.strictEqual(labeled[0]._attention_label, "Conflicts+Feedback");
437
+ assert.strictEqual(labeled[0]._has_attention, true);
438
+
439
+ const config = {
440
+ readiness: {
441
+ require_attention: true,
442
+ },
443
+ };
444
+ const result = evaluateReadiness(labeled[0], config);
445
+
446
+ assert.strictEqual(result.ready, true);
447
+ });
448
+
449
+ it("PR without conflicts or feedback is NOT ready when require_attention is set", () => {
450
+ const items = [{
451
+ number: 999,
452
+ title: "Clean PR",
453
+ user: { login: "author" },
454
+ _mergeable: "MERGEABLE",
455
+ _comments: [
456
+ { user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
457
+ ],
458
+ }];
459
+
460
+ const labeled = computeAttentionLabels(items, {});
461
+
462
+ assert.strictEqual(labeled[0]._attention_label, "PR");
463
+ assert.strictEqual(labeled[0]._has_attention, false);
464
+
465
+ const config = {
466
+ readiness: {
467
+ require_attention: true,
468
+ },
469
+ };
470
+ const result = evaluateReadiness(labeled[0], config);
471
+
472
+ assert.strictEqual(result.ready, false, "PR without attention conditions should NOT be ready");
473
+ assert.ok(result.reason.includes("no attention needed"), "Should have appropriate reason");
474
+ });
475
+
476
+ it("PR with only bot comments is NOT ready when require_attention is NOT set", () => {
477
+ // Without require_attention, the strict bot check applies
478
+ const pr = {
479
+ number: 111,
480
+ title: "Test",
481
+ user: { login: "author" },
482
+ _comments: [
483
+ { user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
484
+ ],
485
+ };
486
+
487
+ const config = {}; // No require_attention
488
+
489
+ const result = evaluateReadiness(pr, config);
490
+
491
+ assert.strictEqual(result.ready, false, "Without require_attention, bot-only comments should fail");
492
+ assert.ok(result.reason.includes("bot"), "Should mention bot in reason");
493
+ });
494
+ });
495
+
496
+ describe("integration: worktree creation with worktree_name", () => {
497
+ let mockServer;
498
+
499
+ afterEach(async () => {
500
+ if (mockServer) {
501
+ await mockServer.close();
502
+ mockServer = null;
503
+ }
504
+ });
505
+
506
+ it("executeAction creates worktree when worktree_name is set but project has no sandboxes", async () => {
507
+ let worktreeListCalled = false;
508
+ let worktreeCreateCalled = false;
509
+ let createdWorktreeName = null;
510
+ let sessionCreated = false;
511
+ let sessionDirectory = null;
512
+
513
+ mockServer = await createMockServer({
514
+ // Project has no sandboxes
515
+ "GET /project": () => ({
516
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
517
+ }),
518
+ "GET /project/current": () => ({
519
+ body: { id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } },
520
+ }),
521
+ // No existing worktrees
522
+ "GET /experimental/worktree": () => {
523
+ worktreeListCalled = true;
524
+ return { body: [] };
525
+ },
526
+ // Worktree creation
527
+ "POST /experimental/worktree": (req) => {
528
+ worktreeCreateCalled = true;
529
+ createdWorktreeName = req.body?.name;
530
+ return {
531
+ body: {
532
+ name: req.body?.name || "new-wt",
533
+ directory: `/worktree/${req.body?.name || "new-wt"}`,
534
+ },
535
+ };
536
+ },
537
+ // No existing sessions
538
+ "GET /session": () => ({ body: [] }),
539
+ "GET /session/status": () => ({ body: {} }),
540
+ // Session creation
541
+ "POST /session": (req) => {
542
+ sessionCreated = true;
543
+ // Extract directory from URL
544
+ const url = new URL(req.path, "http://localhost");
545
+ sessionDirectory = req.query?.directory;
546
+ return { body: { id: "ses_new" } };
547
+ },
548
+ "PATCH /session/ses_new": () => ({ body: {} }),
549
+ "POST /session/ses_new/message": () => ({ body: { success: true } }),
550
+ });
551
+
552
+ const result = await executeAction(
553
+ { number: 42, title: "Review PR" },
554
+ {
555
+ path: "/proj",
556
+ prompt: "review",
557
+ worktree_name: "pr-{number}", // This should trigger worktree creation
558
+ },
559
+ { discoverServer: async () => mockServer.url }
560
+ );
561
+
562
+ assert.ok(result.success, "Action should succeed");
563
+ assert.ok(worktreeListCalled, "Should check for existing worktrees");
564
+ assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
565
+ assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
566
+ assert.ok(sessionCreated, "Should create session");
567
+ assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be in worktree directory");
568
+ });
569
+ });
@@ -830,6 +830,64 @@ Check for bugs and security issues.`;
830
830
  assert.ok(result.command.includes(tempDir),
831
831
  'Should use base directory when no worktree workflow detected');
832
832
  });
833
+
834
+ test('creates worktree when worktree_name configured but no existing sandboxes (dry run)', async () => {
835
+ const { executeAction } = await import('../../service/actions.js');
836
+
837
+ const item = { number: 123, title: 'Review PR' };
838
+ const config = {
839
+ path: tempDir,
840
+ prompt: 'review',
841
+ // worktree_name without worktree: 'new' - preset pattern
842
+ worktree_name: 'pr-{number}'
843
+ };
844
+
845
+ // Mock server discovery
846
+ const mockDiscoverServer = async () => 'http://localhost:4096';
847
+
848
+ let worktreeListCalled = false;
849
+ let worktreeCreateCalled = false;
850
+ let createdWorktreeName = null;
851
+
852
+ const mockFetch = async (url, opts) => {
853
+ // Worktree list endpoint - no existing worktrees
854
+ if (url.startsWith('http://localhost:4096/experimental/worktree') && !opts?.method) {
855
+ worktreeListCalled = true;
856
+ return {
857
+ ok: true,
858
+ json: async () => []
859
+ };
860
+ }
861
+ // Worktree creation endpoint
862
+ if (url.startsWith('http://localhost:4096/experimental/worktree') && opts?.method === 'POST') {
863
+ worktreeCreateCalled = true;
864
+ const body = JSON.parse(opts.body);
865
+ createdWorktreeName = body.name;
866
+ return {
867
+ ok: true,
868
+ json: async () => ({
869
+ name: body.name,
870
+ branch: `opencode/${body.name}`,
871
+ directory: `/data/worktree/proj/${body.name}`
872
+ })
873
+ };
874
+ }
875
+ return { ok: false, text: async () => 'Not found' };
876
+ };
877
+
878
+ const result = await executeAction(item, config, {
879
+ dryRun: true,
880
+ discoverServer: mockDiscoverServer,
881
+ fetch: mockFetch
882
+ });
883
+
884
+ assert.ok(result.dryRun);
885
+ assert.ok(worktreeListCalled, 'Should check for existing worktrees');
886
+ assert.ok(worktreeCreateCalled, 'Should create worktree when worktree_name is configured');
887
+ assert.strictEqual(createdWorktreeName, 'pr-123', 'Should expand worktree_name template');
888
+ assert.ok(result.command.includes('/data/worktree/proj/pr-123'),
889
+ 'Should use new worktree directory');
890
+ });
833
891
  });
834
892
 
835
893
  describe('createSessionViaApi', () => {
@@ -1534,4 +1592,276 @@ Check for bugs and security issues.`;
1534
1592
  assert.strictEqual(sessionCreated, true, 'Should create new session directly');
1535
1593
  });
1536
1594
  });
1595
+
1596
+ describe('buildSessionName', () => {
1597
+ test('expands template with item fields', async () => {
1598
+ const { buildSessionName } = await import('../../service/actions.js');
1599
+
1600
+ const template = 'Review: {title}';
1601
+ const item = { title: 'Fix mobile overflow', number: 123 };
1602
+
1603
+ const result = buildSessionName(template, item);
1604
+
1605
+ assert.strictEqual(result, 'Review: Fix mobile overflow');
1606
+ });
1607
+
1608
+ test('handles nested field references', async () => {
1609
+ const { buildSessionName } = await import('../../service/actions.js');
1610
+
1611
+ const template = '{repository.name} #{number}';
1612
+ const item = { repository: { name: 'my-repo' }, number: 456 };
1613
+
1614
+ const result = buildSessionName(template, item);
1615
+
1616
+ assert.strictEqual(result, 'my-repo #456');
1617
+ });
1618
+
1619
+ test('preserves placeholders for missing fields', async () => {
1620
+ const { buildSessionName } = await import('../../service/actions.js');
1621
+
1622
+ const template = '{label}: {title}';
1623
+ const item = { title: 'Fix bug' }; // no label field
1624
+
1625
+ const result = buildSessionName(template, item);
1626
+
1627
+ assert.strictEqual(result, '{label}: Fix bug');
1628
+ });
1629
+
1630
+ test('handles attention label pattern', async () => {
1631
+ const { buildSessionName } = await import('../../service/actions.js');
1632
+
1633
+ const template = '{_attention_label}: {title}';
1634
+ const item = { _attention_label: 'Conflicts + Feedback', title: 'Update API' };
1635
+
1636
+ const result = buildSessionName(template, item);
1637
+
1638
+ assert.strictEqual(result, 'Conflicts + Feedback: Update API');
1639
+ });
1640
+ });
1641
+
1642
+ describe('listSessions', () => {
1643
+ test('fetches sessions from server with directory filter', async () => {
1644
+ const { listSessions } = await import('../../service/actions.js');
1645
+
1646
+ let capturedUrl = '';
1647
+ const mockFetch = async (url) => {
1648
+ capturedUrl = url;
1649
+ return {
1650
+ ok: true,
1651
+ json: async () => [
1652
+ { id: 'ses_1', title: 'Session 1' },
1653
+ { id: 'ses_2', title: 'Session 2' },
1654
+ ],
1655
+ };
1656
+ };
1657
+
1658
+ const result = await listSessions('http://localhost:4096', {
1659
+ directory: '/Users/test/code/project',
1660
+ fetch: mockFetch,
1661
+ });
1662
+
1663
+ assert.ok(capturedUrl.includes('directory='));
1664
+ assert.ok(capturedUrl.includes(encodeURIComponent('/Users/test/code/project')));
1665
+ assert.ok(capturedUrl.includes('roots=true'));
1666
+ assert.strictEqual(result.length, 2);
1667
+ });
1668
+
1669
+ test('returns empty array on server error', async () => {
1670
+ const { listSessions } = await import('../../service/actions.js');
1671
+
1672
+ const mockFetch = async () => ({
1673
+ ok: false,
1674
+ status: 500,
1675
+ });
1676
+
1677
+ const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
1678
+
1679
+ assert.deepStrictEqual(result, []);
1680
+ });
1681
+
1682
+ test('returns empty array on network error', async () => {
1683
+ const { listSessions } = await import('../../service/actions.js');
1684
+
1685
+ const mockFetch = async () => {
1686
+ throw new Error('Network error');
1687
+ };
1688
+
1689
+ const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
1690
+
1691
+ assert.deepStrictEqual(result, []);
1692
+ });
1693
+
1694
+ test('returns empty array when response is not an array', async () => {
1695
+ const { listSessions } = await import('../../service/actions.js');
1696
+
1697
+ const mockFetch = async () => ({
1698
+ ok: true,
1699
+ json: async () => ({ error: 'unexpected format' }),
1700
+ });
1701
+
1702
+ const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
1703
+
1704
+ assert.deepStrictEqual(result, []);
1705
+ });
1706
+ });
1707
+
1708
+ describe('getSessionStatuses', () => {
1709
+ test('fetches session statuses from server', async () => {
1710
+ const { getSessionStatuses } = await import('../../service/actions.js');
1711
+
1712
+ const mockFetch = async () => ({
1713
+ ok: true,
1714
+ json: async () => ({
1715
+ 'ses_1': { type: 'busy' },
1716
+ 'ses_2': { type: 'idle' },
1717
+ }),
1718
+ });
1719
+
1720
+ const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
1721
+
1722
+ assert.strictEqual(result['ses_1'].type, 'busy');
1723
+ assert.strictEqual(result['ses_2'].type, 'idle');
1724
+ });
1725
+
1726
+ test('returns empty object on server error', async () => {
1727
+ const { getSessionStatuses } = await import('../../service/actions.js');
1728
+
1729
+ const mockFetch = async () => ({
1730
+ ok: false,
1731
+ status: 500,
1732
+ });
1733
+
1734
+ const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
1735
+
1736
+ assert.deepStrictEqual(result, {});
1737
+ });
1738
+
1739
+ test('returns empty object on network error', async () => {
1740
+ const { getSessionStatuses } = await import('../../service/actions.js');
1741
+
1742
+ const mockFetch = async () => {
1743
+ throw new Error('Connection refused');
1744
+ };
1745
+
1746
+ const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
1747
+
1748
+ assert.deepStrictEqual(result, {});
1749
+ });
1750
+ });
1751
+
1752
+ describe('findReusableSession', () => {
1753
+ test('finds best idle session from list', async () => {
1754
+ const { findReusableSession } = await import('../../service/actions.js');
1755
+
1756
+ const mockFetch = async (url) => {
1757
+ if (url.includes('/session/status')) {
1758
+ return {
1759
+ ok: true,
1760
+ json: async () => ({
1761
+ 'ses_busy': { type: 'busy' },
1762
+ // ses_idle not in status map = idle
1763
+ }),
1764
+ };
1765
+ }
1766
+ // GET /session
1767
+ return {
1768
+ ok: true,
1769
+ json: async () => [
1770
+ { id: 'ses_busy', time: { created: 1000, updated: 3000 } },
1771
+ { id: 'ses_idle', time: { created: 1000, updated: 2000 } },
1772
+ ],
1773
+ };
1774
+ };
1775
+
1776
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
1777
+
1778
+ assert.strictEqual(result.id, 'ses_idle');
1779
+ });
1780
+
1781
+ test('returns null when all sessions are archived', async () => {
1782
+ const { findReusableSession } = await import('../../service/actions.js');
1783
+
1784
+ const mockFetch = async (url) => {
1785
+ if (url.includes('/session/status')) {
1786
+ return { ok: true, json: async () => ({}) };
1787
+ }
1788
+ return {
1789
+ ok: true,
1790
+ json: async () => [
1791
+ { id: 'ses_1', time: { created: 1000, updated: 2000, archived: 3000 } },
1792
+ { id: 'ses_2', time: { created: 1000, updated: 2000, archived: 3000 } },
1793
+ ],
1794
+ };
1795
+ };
1796
+
1797
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
1798
+
1799
+ assert.strictEqual(result, null);
1800
+ });
1801
+
1802
+ test('returns null when no sessions exist', async () => {
1803
+ const { findReusableSession } = await import('../../service/actions.js');
1804
+
1805
+ const mockFetch = async (url) => {
1806
+ if (url.includes('/session/status')) {
1807
+ return { ok: true, json: async () => ({}) };
1808
+ }
1809
+ return { ok: true, json: async () => [] };
1810
+ };
1811
+
1812
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
1813
+
1814
+ assert.strictEqual(result, null);
1815
+ });
1816
+
1817
+ test('prefers most recently updated idle session', async () => {
1818
+ const { findReusableSession } = await import('../../service/actions.js');
1819
+
1820
+ const mockFetch = async (url) => {
1821
+ if (url.includes('/session/status')) {
1822
+ return { ok: true, json: async () => ({}) }; // all idle
1823
+ }
1824
+ return {
1825
+ ok: true,
1826
+ json: async () => [
1827
+ { id: 'ses_old', time: { created: 1000, updated: 2000 } },
1828
+ { id: 'ses_new', time: { created: 1000, updated: 5000 } },
1829
+ { id: 'ses_mid', time: { created: 1000, updated: 3000 } },
1830
+ ],
1831
+ };
1832
+ };
1833
+
1834
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
1835
+
1836
+ assert.strictEqual(result.id, 'ses_new');
1837
+ });
1838
+
1839
+ test('falls back to busy session when no idle available', async () => {
1840
+ const { findReusableSession } = await import('../../service/actions.js');
1841
+
1842
+ const mockFetch = async (url) => {
1843
+ if (url.includes('/session/status')) {
1844
+ return {
1845
+ ok: true,
1846
+ json: async () => ({
1847
+ 'ses_busy1': { type: 'busy' },
1848
+ 'ses_busy2': { type: 'retry' },
1849
+ }),
1850
+ };
1851
+ }
1852
+ return {
1853
+ ok: true,
1854
+ json: async () => [
1855
+ { id: 'ses_busy1', time: { created: 1000, updated: 2000 } },
1856
+ { id: 'ses_busy2', time: { created: 1000, updated: 4000 } },
1857
+ ],
1858
+ };
1859
+ };
1860
+
1861
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
1862
+
1863
+ // Should return most recently updated busy session
1864
+ assert.strictEqual(result.id, 'ses_busy2');
1865
+ });
1866
+ });
1537
1867
  });
@@ -154,6 +154,56 @@ sources:
154
154
  assert.strictEqual(config.agent, 'code');
155
155
  });
156
156
 
157
+ test('includes worktree_name from source config', async () => {
158
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
159
+
160
+ const source = {
161
+ name: 'test-source',
162
+ worktree_name: 'pr-{number}'
163
+ };
164
+ const repoConfig = {
165
+ path: '~/code/default'
166
+ };
167
+
168
+ const config = buildActionConfigFromSource(source, repoConfig);
169
+
170
+ assert.strictEqual(config.worktree_name, 'pr-{number}');
171
+ });
172
+
173
+ test('worktree_name from source overrides repoConfig', async () => {
174
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
175
+
176
+ const source = {
177
+ name: 'test-source',
178
+ worktree_name: 'pr-{number}'
179
+ };
180
+ const repoConfig = {
181
+ path: '~/code/default',
182
+ worktree_name: 'issue-{number}' // Should be overridden
183
+ };
184
+
185
+ const config = buildActionConfigFromSource(source, repoConfig);
186
+
187
+ assert.strictEqual(config.worktree_name, 'pr-{number}');
188
+ });
189
+
190
+ test('falls back to repoConfig worktree_name when source has none', async () => {
191
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
192
+
193
+ const source = {
194
+ name: 'test-source'
195
+ // No worktree_name
196
+ };
197
+ const repoConfig = {
198
+ path: '~/code/default',
199
+ worktree_name: 'issue-{number}'
200
+ };
201
+
202
+ const config = buildActionConfigFromSource(source, repoConfig);
203
+
204
+ assert.strictEqual(config.worktree_name, 'issue-{number}');
205
+ });
206
+
157
207
  });
158
208
 
159
209
  describe('per-item repo resolution', () => {
@@ -1059,4 +1059,165 @@ describe('poller.js', () => {
1059
1059
  assert.strictEqual(result[0]._has_attention, false);
1060
1060
  });
1061
1061
  });
1062
+
1063
+ describe('enrichItemsWithComments', () => {
1064
+ test('skips enrichment when filter_bot_comments is not set', async () => {
1065
+ const { enrichItemsWithComments } = await import('../../service/poller.js');
1066
+
1067
+ const items = [{ number: 1, comments: 5, repository_full_name: 'org/repo' }];
1068
+ const source = { tool: { command: ['gh', 'search', 'prs'] } }; // no filter_bot_comments
1069
+
1070
+ const result = await enrichItemsWithComments(items, source);
1071
+
1072
+ // Items returned unchanged, no _comments added
1073
+ assert.strictEqual(result.length, 1);
1074
+ assert.strictEqual(result[0]._comments, undefined);
1075
+ });
1076
+
1077
+ test('skips enrichment for non-GitHub sources', async () => {
1078
+ const { enrichItemsWithComments } = await import('../../service/poller.js');
1079
+
1080
+ const items = [{ number: 1, comments: 5, repository_full_name: 'org/repo' }];
1081
+ const source = {
1082
+ filter_bot_comments: true,
1083
+ tool: { mcp: 'linear', name: 'list_issues' } // not GitHub
1084
+ };
1085
+
1086
+ const result = await enrichItemsWithComments(items, source);
1087
+
1088
+ // Items returned unchanged
1089
+ assert.strictEqual(result.length, 1);
1090
+ assert.strictEqual(result[0]._comments, undefined);
1091
+ });
1092
+
1093
+ test('skips items with zero comments', async () => {
1094
+ const { enrichItemsWithComments } = await import('../../service/poller.js');
1095
+
1096
+ const items = [
1097
+ { number: 1, comments: 0, repository_full_name: 'org/repo' },
1098
+ { number: 2, comments: 0, repository_full_name: 'org/repo' }
1099
+ ];
1100
+ const source = {
1101
+ filter_bot_comments: true,
1102
+ tool: { command: ['gh', 'search', 'prs'] }
1103
+ };
1104
+
1105
+ const result = await enrichItemsWithComments(items, source);
1106
+
1107
+ // Items returned unchanged (no API calls made)
1108
+ assert.strictEqual(result.length, 2);
1109
+ assert.strictEqual(result[0]._comments, undefined);
1110
+ assert.strictEqual(result[1]._comments, undefined);
1111
+ });
1112
+
1113
+ test('identifies GitHub MCP source correctly', async () => {
1114
+ const { enrichItemsWithComments } = await import('../../service/poller.js');
1115
+
1116
+ const items = [{ number: 1, comments: 0, repository_full_name: 'org/repo' }];
1117
+ const source = {
1118
+ filter_bot_comments: true,
1119
+ tool: { mcp: 'github', name: 'search_issues' }
1120
+ };
1121
+
1122
+ // Should not throw, just skip due to 0 comments
1123
+ const result = await enrichItemsWithComments(items, source);
1124
+ assert.strictEqual(result.length, 1);
1125
+ });
1126
+
1127
+ test('identifies GitHub CLI source correctly', async () => {
1128
+ const { enrichItemsWithComments } = await import('../../service/poller.js');
1129
+
1130
+ const items = [{ number: 1, comments: 0, repository_full_name: 'org/repo' }];
1131
+ const source = {
1132
+ filter_bot_comments: true,
1133
+ tool: { command: ['gh', 'search', 'issues', '--json', 'number'] }
1134
+ };
1135
+
1136
+ // Should not throw, just skip due to 0 comments
1137
+ const result = await enrichItemsWithComments(items, source);
1138
+ assert.strictEqual(result.length, 1);
1139
+ });
1140
+ });
1141
+
1142
+ describe('enrichItemsWithMergeable', () => {
1143
+ test('skips enrichment when enrich_mergeable is not set', async () => {
1144
+ const { enrichItemsWithMergeable } = await import('../../service/poller.js');
1145
+
1146
+ const items = [{ number: 1, repository_full_name: 'org/repo' }];
1147
+ const source = {}; // no enrich_mergeable
1148
+
1149
+ const result = await enrichItemsWithMergeable(items, source);
1150
+
1151
+ // Items returned unchanged
1152
+ assert.strictEqual(result.length, 1);
1153
+ assert.strictEqual(result[0]._mergeable, undefined);
1154
+ });
1155
+
1156
+ test('skips items without repository info', async () => {
1157
+ const { enrichItemsWithMergeable } = await import('../../service/poller.js');
1158
+
1159
+ const items = [
1160
+ { number: 1 }, // no repository_full_name
1161
+ { repository_full_name: 'org/repo' } // no number
1162
+ ];
1163
+ const source = { enrich_mergeable: true };
1164
+
1165
+ const result = await enrichItemsWithMergeable(items, source);
1166
+
1167
+ // Items returned unchanged (no API calls made for invalid items)
1168
+ assert.strictEqual(result.length, 2);
1169
+ assert.strictEqual(result[0]._mergeable, undefined);
1170
+ assert.strictEqual(result[1]._mergeable, undefined);
1171
+ });
1172
+
1173
+ test('accepts repository.nameWithOwner as alternative field', async () => {
1174
+ const { enrichItemsWithMergeable } = await import('../../service/poller.js');
1175
+
1176
+ const items = [
1177
+ { number: 1, repository: { nameWithOwner: 'org/repo' } }
1178
+ ];
1179
+ const source = { enrich_mergeable: true };
1180
+
1181
+ // This will attempt the API call (which may fail in test env)
1182
+ // but it should not skip due to missing repo info
1183
+ const result = await enrichItemsWithMergeable(items, source);
1184
+
1185
+ // Should have attempted enrichment (result may have _mergeable: null on CLI error)
1186
+ assert.strictEqual(result.length, 1);
1187
+ // The item should have been processed (not skipped)
1188
+ assert.ok('_mergeable' in result[0] || result[0]._mergeable === undefined);
1189
+ });
1190
+ });
1191
+
1192
+ describe('fetchGitHubComments', () => {
1193
+ test('returns empty array when repository_full_name is missing', async () => {
1194
+ const { fetchGitHubComments } = await import('../../service/poller.js');
1195
+
1196
+ const item = { number: 123 }; // no repository_full_name
1197
+
1198
+ const result = await fetchGitHubComments(item);
1199
+
1200
+ assert.deepStrictEqual(result, []);
1201
+ });
1202
+
1203
+ test('returns empty array when number is missing', async () => {
1204
+ const { fetchGitHubComments } = await import('../../service/poller.js');
1205
+
1206
+ const item = { repository_full_name: 'org/repo' }; // no number
1207
+
1208
+ const result = await fetchGitHubComments(item);
1209
+
1210
+ assert.deepStrictEqual(result, []);
1211
+ });
1212
+
1213
+ test('returns empty array when owner/repo cannot be parsed', async () => {
1214
+ const { fetchGitHubComments } = await import('../../service/poller.js');
1215
+
1216
+ const item = { repository_full_name: 'invalid', number: 123 }; // no slash
1217
+
1218
+ const result = await fetchGitHubComments(item);
1219
+
1220
+ assert.deepStrictEqual(result, []);
1221
+ });
1222
+ });
1062
1223
  });
@@ -675,5 +675,56 @@ describe('readiness.js', () => {
675
675
 
676
676
  assert.strictEqual(result.ready, true);
677
677
  });
678
+
679
+ test('passes when PR has conflicts but only bot comments (require_attention skips bot check)', async () => {
680
+ const { evaluateReadiness } = await import('../../service/readiness.js');
681
+
682
+ // This is the key scenario: PR has merge conflicts but no human feedback
683
+ // With require_attention, the checkBotComments check should be skipped
684
+ // because checkAttention handles the combined logic via _has_attention
685
+ const pr = {
686
+ number: 123,
687
+ title: 'Test PR',
688
+ user: { login: 'author' },
689
+ _mergeable: 'CONFLICTING',
690
+ _comments: [
691
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
692
+ ],
693
+ _has_attention: true,
694
+ _attention_label: 'Conflicts'
695
+ };
696
+ const config = {
697
+ readiness: {
698
+ require_attention: true
699
+ }
700
+ };
701
+
702
+ const result = evaluateReadiness(pr, config);
703
+
704
+ // Should be ready because it has conflicts (via _has_attention)
705
+ // even though it only has bot comments
706
+ assert.strictEqual(result.ready, true);
707
+ });
708
+
709
+ test('still fails bot check when require_attention is NOT configured', async () => {
710
+ const { evaluateReadiness } = await import('../../service/readiness.js');
711
+
712
+ // Without require_attention, the strict bot check applies
713
+ const pr = {
714
+ number: 123,
715
+ title: 'Test PR',
716
+ user: { login: 'author' },
717
+ _comments: [
718
+ { user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
719
+ ]
720
+ };
721
+ const config = {};
722
+
723
+ const result = evaluateReadiness(pr, config);
724
+
725
+ // Should fail because no human feedback and no require_attention bypass
726
+ assert.strictEqual(result.ready, false);
727
+ assert.ok(result.reason.includes('bot'));
728
+ });
678
729
  });
679
730
  });
@@ -874,6 +874,45 @@ sources:
874
874
  assert.strictEqual(sources[0].session.name, '{title}', 'linear preset should use title');
875
875
  });
876
876
 
877
+ test('github presets include worktree_name for sandbox reuse', async () => {
878
+ writeFileSync(configPath, `
879
+ sources:
880
+ - preset: github/my-issues
881
+ - preset: github/review-requests
882
+ - preset: github/my-prs-attention
883
+ `);
884
+
885
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
886
+ loadRepoConfig(configPath);
887
+ const sources = getSources();
888
+
889
+ // my-issues: worktree_name: "issue-{number}"
890
+ assert.strictEqual(sources[0].worktree_name, 'issue-{number}', 'my-issues should use issue-{number}');
891
+
892
+ // review-requests: worktree_name: "pr-{number}"
893
+ assert.strictEqual(sources[1].worktree_name, 'pr-{number}', 'review-requests should use pr-{number}');
894
+
895
+ // my-prs-attention: worktree_name: "pr-{number}"
896
+ assert.strictEqual(sources[2].worktree_name, 'pr-{number}', 'my-prs-attention should use pr-{number}');
897
+ });
898
+
899
+ test('linear preset includes worktree_name for sandbox reuse', async () => {
900
+ writeFileSync(configPath, `
901
+ sources:
902
+ - preset: linear/my-issues
903
+ args:
904
+ teamId: "team-uuid"
905
+ assigneeId: "user-uuid"
906
+ `);
907
+
908
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
909
+ loadRepoConfig(configPath);
910
+ const sources = getSources();
911
+
912
+ // linear uses the issue identifier (e.g., "ABC-123")
913
+ assert.strictEqual(sources[0].worktree_name, '{number}', 'linear preset should use {number} (identifier)');
914
+ });
915
+
877
916
  });
878
917
 
879
918
  describe('shorthand syntax', () => {
@@ -86,5 +86,101 @@ describe('service/server.js', () => {
86
86
 
87
87
  assert.strictEqual(res.status, 404);
88
88
  });
89
+
90
+ test('returns 404 for POST to health', async () => {
91
+ const { startService } = await import('../../service/server.js');
92
+
93
+ service = await startService({
94
+ httpPort: 0,
95
+ enablePolling: false
96
+ });
97
+
98
+ const port = service.httpServer.address().port;
99
+ const res = await fetch(`http://localhost:${port}/health`, {
100
+ method: 'POST',
101
+ body: '{}'
102
+ });
103
+
104
+ assert.strictEqual(res.status, 404);
105
+ });
106
+ });
107
+
108
+ describe('startService and stopService', () => {
109
+ test('starts and stops cleanly', async () => {
110
+ const { startService, stopService } = await import('../../service/server.js');
111
+
112
+ const localService = await startService({
113
+ httpPort: 0,
114
+ enablePolling: false
115
+ });
116
+
117
+ assert.ok(localService.httpServer, 'Should have httpServer');
118
+ assert.strictEqual(localService.pollingState, null, 'Should not have pollingState when disabled');
119
+
120
+ const port = localService.httpServer.address().port;
121
+ assert.ok(port > 0, 'Should have valid port');
122
+
123
+ await stopService(localService);
124
+
125
+ // Server should be closed - attempting to fetch should fail
126
+ try {
127
+ await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(500) });
128
+ assert.fail('Fetch should have failed after stop');
129
+ } catch (err) {
130
+ // Expected - connection refused or timeout
131
+ assert.ok(err.message.includes('fetch failed') || err.name === 'TimeoutError' || err.name === 'AbortError');
132
+ }
133
+ });
134
+
135
+ test('handles stopService on already stopped service', async () => {
136
+ const { startService, stopService } = await import('../../service/server.js');
137
+
138
+ const localService = await startService({
139
+ httpPort: 0,
140
+ enablePolling: false
141
+ });
142
+
143
+ // Stop twice - should not throw
144
+ await stopService(localService);
145
+ await stopService(localService);
146
+ });
147
+
148
+ test('starts with enablePolling=false when no config exists', async () => {
149
+ const { startService, stopService } = await import('../../service/server.js');
150
+
151
+ const localService = await startService({
152
+ httpPort: 0,
153
+ enablePolling: true, // enabled but config doesn't exist
154
+ reposConfig: '/nonexistent/config.yaml'
155
+ });
156
+
157
+ // Should start without polling since config doesn't exist
158
+ assert.ok(localService.httpServer);
159
+ assert.strictEqual(localService.pollingState, null);
160
+
161
+ await stopService(localService);
162
+ });
163
+ });
164
+
165
+ describe('CORS headers', () => {
166
+ test('OPTIONS includes all required headers', async () => {
167
+ const { startService } = await import('../../service/server.js');
168
+
169
+ service = await startService({
170
+ httpPort: 0,
171
+ enablePolling: false
172
+ });
173
+
174
+ const port = service.httpServer.address().port;
175
+ const res = await fetch(`http://localhost:${port}/anything`, {
176
+ method: 'OPTIONS'
177
+ });
178
+
179
+ assert.strictEqual(res.status, 204);
180
+ assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
181
+ assert.strictEqual(res.headers.get('access-control-allow-methods'), 'GET, OPTIONS');
182
+ assert.ok(res.headers.get('access-control-allow-headers').includes('Content-Type'));
183
+ assert.strictEqual(res.headers.get('access-control-max-age'), '86400');
184
+ });
89
185
  });
90
186
  });