@stilero/bankan 1.0.21 → 1.1.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.
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-loJ8KKB-.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DBbtVfOb.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-BZkAflU1.css">
12
12
  </head>
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stilero/bankan",
3
- "version": "1.0.21",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Run AI coding agents like a Kanban board. Plan, implement, review and ship code using parallel AI agents across your local repositories.",
6
6
  "license": "MIT",
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const CAPABILITIES_TTL_MS = 30_000;
4
+
5
+ let cachedCapabilities = null;
6
+ let cachedAt = 0;
7
+
8
+ function canRunGhCommand(args) {
9
+ try {
10
+ execFileSync('gh', args, { stdio: 'pipe', encoding: 'utf-8' });
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export function getGithubCapabilities() {
18
+ const now = Date.now();
19
+ if (cachedCapabilities && now - cachedAt < CAPABILITIES_TTL_MS) {
20
+ return cachedCapabilities;
21
+ }
22
+
23
+ const ghAvailable = canRunGhCommand(['--version']);
24
+ const ghAuthenticated = ghAvailable ? canRunGhCommand(['auth', 'status']) : false;
25
+
26
+ cachedCapabilities = {
27
+ ghAvailable,
28
+ ghAuthenticated,
29
+ canCreatePullRequests: ghAvailable && ghAuthenticated,
30
+ };
31
+ cachedAt = now;
32
+ return cachedCapabilities;
33
+ }
34
+
35
+ export function isManualPullRequestRequired(capabilities = getGithubCapabilities()) {
36
+ return !capabilities.canCreatePullRequests;
37
+ }
38
+
39
+ export function resetGithubCapabilitiesCache() {
40
+ cachedCapabilities = null;
41
+ cachedAt = 0;
42
+ }
@@ -0,0 +1,50 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+
3
+ const execFileSyncMock = vi.fn();
4
+
5
+ vi.mock('node:child_process', () => ({
6
+ execFileSync: execFileSyncMock,
7
+ }));
8
+
9
+ describe('GitHub capabilities', () => {
10
+ beforeEach(async () => {
11
+ vi.resetModules();
12
+ execFileSyncMock.mockReset();
13
+ const { resetGithubCapabilitiesCache } = await import('./capabilities.js');
14
+ resetGithubCapabilitiesCache();
15
+ });
16
+
17
+ test('caches capability checks within the TTL', async () => {
18
+ execFileSyncMock.mockImplementation(() => 'ok');
19
+ const { getGithubCapabilities } = await import('./capabilities.js');
20
+
21
+ const first = getGithubCapabilities();
22
+ const second = getGithubCapabilities();
23
+
24
+ expect(first).toEqual({
25
+ ghAvailable: true,
26
+ ghAuthenticated: true,
27
+ canCreatePullRequests: true,
28
+ });
29
+ expect(second).toEqual(first);
30
+ expect(execFileSyncMock).toHaveBeenCalledTimes(2);
31
+ });
32
+
33
+ test('recomputes capabilities after cache reset', async () => {
34
+ execFileSyncMock
35
+ .mockImplementationOnce(() => 'ok')
36
+ .mockImplementationOnce(() => 'ok')
37
+ .mockImplementationOnce(() => {
38
+ throw new Error('gh missing');
39
+ });
40
+ const { getGithubCapabilities, resetGithubCapabilitiesCache } = await import('./capabilities.js');
41
+
42
+ expect(getGithubCapabilities().canCreatePullRequests).toBe(true);
43
+ resetGithubCapabilitiesCache();
44
+ expect(getGithubCapabilities()).toEqual({
45
+ ghAvailable: false,
46
+ ghAuthenticated: false,
47
+ canCreatePullRequests: false,
48
+ });
49
+ });
50
+ });
@@ -8,10 +8,16 @@ import { resolve, dirname as pathDirname, join } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir, getRuntimeStatePaths } from './config.js';
11
+ import { getGithubCapabilities } from './capabilities.js';
11
12
  import store from './store.js';
12
13
  import agentManager from './agents.js';
13
14
  import bus from './events.js';
14
- import { getLiveTaskAgent, stageToRetryStatus } from './workflow.js';
15
+ import {
16
+ buildMaxReviewBlockerApprovalUpdate,
17
+ buildMaxReviewBlockerExtensionUpdate,
18
+ getLiveTaskAgent,
19
+ stageToRetryStatus,
20
+ } from './workflow.js';
15
21
  import { createSessionEntry } from './sessionHistory.js';
16
22
 
17
23
  const app = express();
@@ -55,6 +61,29 @@ function resolveRetryStatus(task) {
55
61
  return stageToRetryStatus(task, { planningDisabled, liveAgent });
56
62
  }
57
63
 
64
+ export function approveMaxReviewBlocker(taskId) {
65
+ const task = store.getTask(taskId);
66
+ const updates = buildMaxReviewBlockerApprovalUpdate(task);
67
+ if (!updates) return false;
68
+ if (task?.workspacePath && existsSync(task.workspacePath)) {
69
+ rmSync(task.workspacePath, { recursive: true, force: true });
70
+ }
71
+ store.updateTask(taskId, updates);
72
+ store.appendLog(taskId, 'Human override: approved task to done after max review cycles.');
73
+ bus.emit('max-review-blocker:approved', { taskId });
74
+ return true;
75
+ }
76
+
77
+ function extendMaxReviewBlocker(taskId) {
78
+ const task = store.getTask(taskId);
79
+ const updates = buildMaxReviewBlockerExtensionUpdate(task);
80
+ if (!updates) return false;
81
+ store.updateTask(taskId, updates);
82
+ store.appendLog(taskId, `Human override: increased review limit to ${updates.maxReviewCycles}.`);
83
+ bus.emit('max-review-blocker:extended', { taskId, maxReviewCycles: updates.maxReviewCycles });
84
+ return true;
85
+ }
86
+
58
87
  // REST API
59
88
  app.get('/api/status', (req, res) => {
60
89
  res.json({
@@ -121,6 +150,24 @@ app.patch('/api/tasks/:id/reject', (req, res) => {
121
150
  res.json({ ok: true });
122
151
  });
123
152
 
153
+ app.patch('/api/tasks/:id/approve-max-review-blocker', (req, res) => {
154
+ const task = store.getTask(req.params.id);
155
+ if (!task) return res.status(404).json({ error: 'Task not found' });
156
+ if (!approveMaxReviewBlocker(task.id)) {
157
+ return res.status(400).json({ error: 'Task is not blocked by maximum review cycles' });
158
+ }
159
+ res.json({ ok: true });
160
+ });
161
+
162
+ app.patch('/api/tasks/:id/extend-max-review-blocker', (req, res) => {
163
+ const task = store.getTask(req.params.id);
164
+ if (!task) return res.status(404).json({ error: 'Task not found' });
165
+ if (!extendMaxReviewBlocker(task.id)) {
166
+ return res.status(400).json({ error: 'Task is not blocked by maximum review cycles' });
167
+ }
168
+ res.json({ ok: true });
169
+ });
170
+
124
171
  app.delete('/api/tasks/:id', async (req, res) => {
125
172
  const task = store.getTask(req.params.id);
126
173
  if (!task) return res.status(404).json({ error: 'Task not found' });
@@ -445,6 +492,7 @@ wss.on('connection', (ws) => {
445
492
  agents: agentManager.getAllStatus(),
446
493
  repos: loadSettings().repos || [],
447
494
  settings: loadSettings(),
495
+ capabilities: getGithubCapabilities(),
448
496
  },
449
497
  ts: Date.now(),
450
498
  }));
@@ -539,6 +587,11 @@ wss.on('connection', (ws) => {
539
587
  }
540
588
  break;
541
589
  }
590
+ case 'COMPLETE_MANUAL_PR': {
591
+ const { taskId } = msg.payload || {};
592
+ if (taskId) orchestrator.completeManualPr(taskId);
593
+ break;
594
+ }
542
595
  case 'RETRY_TASK': {
543
596
  const { taskId } = msg.payload || {};
544
597
  const task = store.getTask(taskId);
@@ -560,6 +613,16 @@ wss.on('connection', (ws) => {
560
613
  }
561
614
  break;
562
615
  }
616
+ case 'APPROVE_MAX_REVIEW_BLOCKER': {
617
+ const { taskId } = msg.payload || {};
618
+ if (taskId) approveMaxReviewBlocker(taskId);
619
+ break;
620
+ }
621
+ case 'EXTEND_MAX_REVIEW_BLOCKER': {
622
+ const { taskId } = msg.payload || {};
623
+ if (taskId) extendMaxReviewBlocker(taskId);
624
+ break;
625
+ }
563
626
  case 'EDIT_TASK': {
564
627
  const { taskId, updates } = msg.payload || {};
565
628
  const task = store.getTask(taskId);
@@ -693,6 +756,9 @@ bus.on('review:passed', (data) => broadcast('REVIEW_PASSED', data));
693
756
  bus.on('review:failed', (data) => broadcast('REVIEW_FAILED', data));
694
757
  bus.on('pr:created', (data) => broadcast('PR_CREATED', data));
695
758
  bus.on('task:blocked', (data) => broadcast('TASK_BLOCKED', data));
759
+ bus.on('task:manual-pr-required', (data) => broadcast('TASK_MANUAL_PR_REQUIRED', data));
760
+ bus.on('max-review-blocker:approved', (data) => broadcast('MAX_REVIEW_BLOCKER_APPROVED', data));
761
+ bus.on('max-review-blocker:extended', (data) => broadcast('MAX_REVIEW_BLOCKER_EXTENDED', data));
696
762
  bus.on('repos:updated', (repos) => broadcast('REPOS_UPDATED', { repos }));
697
763
  bus.on('plan:partial', (data) => broadcast('PLAN_PARTIAL', data));
698
764
  bus.on('task:aborted', (data) => broadcast('TASK_ABORTED', data));
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const mockStore = {
7
+ getTask: vi.fn(),
8
+ updateTask: vi.fn(),
9
+ appendLog: vi.fn(),
10
+ restartRecovery: vi.fn(),
11
+ };
12
+
13
+ const mockBus = {
14
+ emit: vi.fn(),
15
+ on: vi.fn(),
16
+ };
17
+
18
+ vi.mock('./store.js', () => ({
19
+ default: mockStore,
20
+ }));
21
+
22
+ vi.mock('./events.js', () => ({
23
+ default: mockBus,
24
+ }));
25
+
26
+ const { approveMaxReviewBlocker } = await import('./index.js');
27
+
28
+ describe('approveMaxReviewBlocker', () => {
29
+ beforeEach(() => {
30
+ mockStore.getTask.mockReset();
31
+ mockStore.updateTask.mockReset();
32
+ mockStore.appendLog.mockReset();
33
+ mockStore.restartRecovery.mockReset();
34
+ mockBus.emit.mockReset();
35
+ mockBus.on.mockClear();
36
+ });
37
+
38
+ test('cleans up the workspace directory and clears workspacePath when approving to done', () => {
39
+ const workspacePath = join(tmpdir(), `bankan-approve-${Date.now()}`);
40
+ mkdirSync(workspacePath, { recursive: true });
41
+ const task = {
42
+ id: 'T-1',
43
+ status: 'blocked',
44
+ blockedReason: 'Reached maximum review cycles (3). Human input required.',
45
+ workspacePath,
46
+ };
47
+ mockStore.getTask.mockReturnValue(task);
48
+
49
+ expect(existsSync(workspacePath)).toBe(true);
50
+
51
+ expect(approveMaxReviewBlocker('T-1')).toBe(true);
52
+
53
+ expect(existsSync(workspacePath)).toBe(false);
54
+ expect(mockStore.updateTask).toHaveBeenCalledWith('T-1', expect.objectContaining({
55
+ status: 'done',
56
+ workspacePath: null,
57
+ }));
58
+ expect(mockStore.appendLog).toHaveBeenCalledWith(
59
+ 'T-1',
60
+ 'Human override: approved task to done after max review cycles.'
61
+ );
62
+ expect(mockBus.emit).toHaveBeenCalledWith('max-review-blocker:approved', { taskId: 'T-1' });
63
+ rmSync(workspacePath, { recursive: true, force: true });
64
+ });
65
+ });
@@ -4,10 +4,18 @@ import { join } from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { simpleGit } from 'simple-git';
6
6
  import { loadSettings, getWorkspacesDir } from './config.js';
7
+ import { getGithubCapabilities, isManualPullRequestRequired } from './capabilities.js';
7
8
  import store from './store.js';
8
9
  import agentManager from './agents.js';
9
10
  import bus from './events.js';
10
- import { isReviewResultPlaceholder, isPlanPlaceholder, isImplementationPlaceholder, parseReviewResult, reviewShouldPass } from './workflow.js';
11
+ import {
12
+ isReviewResultPlaceholder,
13
+ isPlanPlaceholder,
14
+ isImplementationPlaceholder,
15
+ parseReviewResult,
16
+ resolveTaskMaxReviewCycles,
17
+ reviewShouldPass,
18
+ } from './workflow.js';
11
19
  import { createSessionEntry } from './sessionHistory.js';
12
20
 
13
21
  const POLL_INTERVAL = 4000;
@@ -16,7 +24,7 @@ const PLANNER_TIMEOUT = 5 * 60 * 1000;
16
24
  const IMPLEMENTOR_TIMEOUT = 60 * 60 * 1000;
17
25
  const REVIEWER_TIMEOUT = 30 * 60 * 1000;
18
26
  const STUCK_TIMEOUT = 10 * 60 * 1000;
19
- const MAX_REVIEW_CYCLES = 3;
27
+ const DEFAULT_MAX_REVIEW_CYCLES = 3;
20
28
 
21
29
  let pollTimer = null;
22
30
  let signalTimer = null;
@@ -701,6 +709,42 @@ async function cleanupWorkspace(task) {
701
709
  }
702
710
  }
703
711
 
712
+ function buildManualPrGuidance(task, capabilities = getGithubCapabilities()) {
713
+ const reason = !capabilities.ghAvailable
714
+ ? 'GitHub CLI is not installed'
715
+ : !capabilities.ghAuthenticated
716
+ ? 'GitHub CLI is not authenticated'
717
+ : 'Automatic pull request creation is unavailable';
718
+
719
+ return `${reason}, so Ban Kan could not create the pull request automatically. Your branch has been pushed${task?.branch ? ` (${task.branch})` : ''}. Open the workspace, create the PR manually, then mark this task done.`;
720
+ }
721
+
722
+ function isManualPrAutomationError(err) {
723
+ if (!err) return false;
724
+ const message = typeof err.message === 'string' ? err.message : '';
725
+ const path = typeof err.path === 'string' ? err.path : '';
726
+ const spawnargs = Array.isArray(err.spawnargs) ? err.spawnargs : [];
727
+ const firstSpawnArg = typeof spawnargs[0] === 'string' ? spawnargs[0] : '';
728
+ return path === 'gh'
729
+ || firstSpawnArg === 'gh'
730
+ || /spawn(?:sync)? gh ENOENT/i.test(message)
731
+ || /gh.*not authenticated/i.test(message);
732
+ }
733
+
734
+ async function transitionTaskToManualPr(taskId, capabilities = getGithubCapabilities()) {
735
+ const task = store.getTask(taskId);
736
+ if (!task) return;
737
+
738
+ const blockedReason = buildManualPrGuidance(task, capabilities);
739
+ store.updateTask(taskId, {
740
+ status: 'awaiting_manual_pr',
741
+ assignedTo: null,
742
+ blockedReason,
743
+ });
744
+ store.appendLog(taskId, 'Automatic PR creation unavailable; waiting for manual PR completion.');
745
+ bus.emit('task:manual-pr-required', { taskId, reason: blockedReason });
746
+ }
747
+
704
748
  function retireAgentSession(agent, {
705
749
  taskId = agent?.currentTask || null,
706
750
  outcome = 'completed',
@@ -738,6 +782,7 @@ async function startPlanning(task) {
738
782
  review: null,
739
783
  reviewFeedback: null,
740
784
  reviewCycleCount: 0,
785
+ maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
741
786
  blockedReason: null,
742
787
  assignedTo: null,
743
788
  });
@@ -810,6 +855,7 @@ function onPlanComplete(agentId, taskId) {
810
855
 
811
856
  // Save plan
812
857
  store.savePlan(taskId, planText);
858
+ const task = store.getTask(taskId);
813
859
  store.updateTask(taskId, {
814
860
  status: 'awaiting_approval',
815
861
  plan: planText,
@@ -817,6 +863,7 @@ function onPlanComplete(agentId, taskId) {
817
863
  review: null,
818
864
  reviewFeedback: null,
819
865
  reviewCycleCount: 0,
866
+ maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
820
867
  blockedReason: null,
821
868
  assignedTo: null,
822
869
  });
@@ -997,13 +1044,14 @@ async function onReviewComplete(agentId, taskId) {
997
1044
 
998
1045
  const task = store.getTask(taskId);
999
1046
  const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
1047
+ const maxReviewCycles = Math.max(1, task?.maxReviewCycles || DEFAULT_MAX_REVIEW_CYCLES);
1000
1048
 
1001
- if (nextReviewCycleCount >= MAX_REVIEW_CYCLES) {
1049
+ if (nextReviewCycleCount >= maxReviewCycles) {
1002
1050
  store.updateTask(taskId, {
1003
1051
  status: 'blocked',
1004
1052
  reviewFeedback: criticalIssues,
1005
1053
  reviewCycleCount: nextReviewCycleCount,
1006
- blockedReason: `Reached maximum review cycles (${MAX_REVIEW_CYCLES}). Human input required.`,
1054
+ blockedReason: `Reached maximum review cycles (${maxReviewCycles}). Human input required.`,
1007
1055
  assignedTo: null,
1008
1056
  });
1009
1057
  bus.emit('task:blocked', { taskId, reason: 'Reached maximum review cycles' });
@@ -1021,7 +1069,7 @@ async function onReviewComplete(agentId, taskId) {
1021
1069
  }
1022
1070
  }
1023
1071
 
1024
- async function createPR(taskId) {
1072
+ export async function createPR(taskId) {
1025
1073
  const task = store.getTask(taskId);
1026
1074
  try {
1027
1075
  if (!task?.workspacePath || !existsSync(task.workspacePath)) {
@@ -1040,6 +1088,12 @@ async function createPR(taskId) {
1040
1088
  }
1041
1089
 
1042
1090
  await git.raw(['push', '--force-with-lease', 'origin', task.branch]);
1091
+ const githubCapabilities = getGithubCapabilities();
1092
+ if (isManualPullRequestRequired(githubCapabilities)) {
1093
+ await transitionTaskToManualPr(taskId, githubCapabilities);
1094
+ return;
1095
+ }
1096
+
1043
1097
  const prBody = buildPullRequestBody(task);
1044
1098
  const prUrl = execFileSync('gh', [
1045
1099
  'pr', 'create',
@@ -1058,6 +1112,10 @@ async function createPR(taskId) {
1058
1112
  await cleanupWorkspace(store.getTask(taskId));
1059
1113
  store.updateTask(taskId, { status: 'done', assignedTo: null });
1060
1114
  } catch (err) {
1115
+ if (isManualPrAutomationError(err)) {
1116
+ await transitionTaskToManualPr(taskId);
1117
+ return;
1118
+ }
1061
1119
  console.error('PR creation error:', err.message);
1062
1120
  store.updateTask(taskId, {
1063
1121
  status: 'blocked',
@@ -1068,6 +1126,20 @@ async function createPR(taskId) {
1068
1126
  }
1069
1127
  }
1070
1128
 
1129
+ async function completeManualPr(taskId) {
1130
+ const task = store.getTask(taskId);
1131
+ if (!task || task.status !== 'awaiting_manual_pr') return;
1132
+
1133
+ await cleanupWorkspace(task);
1134
+ store.updateTask(taskId, {
1135
+ status: 'done',
1136
+ assignedTo: null,
1137
+ blockedReason: null,
1138
+ completedAt: new Date().toISOString(),
1139
+ });
1140
+ bus.emit('task:manual-pr-completed', { taskId });
1141
+ }
1142
+
1071
1143
  async function abortTask(taskId) {
1072
1144
  const task = store.getTask(taskId);
1073
1145
  if (!task || task.status === 'done') return;
@@ -1087,6 +1159,7 @@ async function abortTask(taskId) {
1087
1159
  reviewFeedback: null,
1088
1160
  previousStatus: null,
1089
1161
  reviewCycleCount: 0,
1162
+ maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1090
1163
  });
1091
1164
 
1092
1165
  bus.emit('task:aborted', { taskId });
@@ -1118,6 +1191,7 @@ async function resetTask(taskId) {
1118
1191
  planFeedback: null,
1119
1192
  previousStatus: null,
1120
1193
  reviewCycleCount: 0,
1194
+ maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1121
1195
  sessionHistory: [],
1122
1196
  progress: 0,
1123
1197
  totalTokens: 0,
@@ -1476,6 +1550,7 @@ const orchestrator = {
1476
1550
  abortTask,
1477
1551
  resetTask,
1478
1552
  deleteTask,
1553
+ completeManualPr,
1479
1554
  };
1480
1555
 
1481
1556
  export default orchestrator;
@@ -0,0 +1,177 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+
3
+ const task = {
4
+ id: 'T-42',
5
+ title: 'Manual PR fallback',
6
+ description: 'Fallback when gh is missing',
7
+ priority: 'high',
8
+ branch: 'feature/t-42-manual-pr',
9
+ workspacePath: '/tmp/workspaces/T-42',
10
+ assignedTo: 'orch',
11
+ status: 'review',
12
+ repoPath: '/repo',
13
+ review: 'Looks good',
14
+ plan: 'Do the thing',
15
+ reviewFeedback: null,
16
+ blockedReason: null,
17
+ };
18
+
19
+ const getTask = vi.fn();
20
+ const updateTask = vi.fn();
21
+ const appendLog = vi.fn();
22
+ const emit = vi.fn();
23
+ const fetchMock = vi.fn();
24
+ const checkoutMock = vi.fn();
25
+ const rebaseMock = vi.fn();
26
+ const rawMock = vi.fn();
27
+ const existsSyncMock = vi.fn();
28
+ const execFileSyncMock = vi.fn();
29
+
30
+ vi.mock('./store.js', () => ({
31
+ default: {
32
+ getTask,
33
+ updateTask,
34
+ appendLog,
35
+ },
36
+ }));
37
+
38
+ vi.mock('./events.js', () => ({
39
+ default: {
40
+ emit,
41
+ on: vi.fn(),
42
+ },
43
+ }));
44
+
45
+ vi.mock('./agents.js', () => ({
46
+ default: {
47
+ get: vi.fn(),
48
+ getAvailablePlanner: vi.fn(),
49
+ getAvailableImplementor: vi.fn(),
50
+ getAvailableReviewer: vi.fn(),
51
+ getAllStatus: vi.fn(() => []),
52
+ agents: new Map(),
53
+ reconfigure: vi.fn(),
54
+ },
55
+ }));
56
+
57
+ vi.mock('./config.js', () => ({
58
+ loadSettings: vi.fn(() => ({
59
+ agents: {
60
+ planners: { max: 1 },
61
+ implementors: { max: 1 },
62
+ reviewers: { max: 1 },
63
+ },
64
+ })),
65
+ getWorkspacesDir: vi.fn(() => '/tmp/workspaces'),
66
+ }));
67
+
68
+ vi.mock('simple-git', () => ({
69
+ simpleGit: vi.fn(() => ({
70
+ fetch: fetchMock,
71
+ checkout: checkoutMock,
72
+ rebase: rebaseMock,
73
+ raw: rawMock,
74
+ })),
75
+ }));
76
+
77
+ vi.mock('node:fs', async () => {
78
+ const actual = await vi.importActual('node:fs');
79
+ return {
80
+ ...actual,
81
+ existsSync: existsSyncMock,
82
+ mkdirSync: vi.fn(),
83
+ readFileSync: vi.fn(() => ''),
84
+ readdirSync: vi.fn(() => []),
85
+ unlinkSync: vi.fn(),
86
+ };
87
+ });
88
+
89
+ vi.mock('node:fs/promises', () => ({
90
+ rm: vi.fn(),
91
+ }));
92
+
93
+ vi.mock('node:child_process', () => ({
94
+ execFileSync: execFileSyncMock,
95
+ }));
96
+
97
+ vi.mock('./workflow.js', () => ({
98
+ isReviewResultPlaceholder: vi.fn(() => false),
99
+ isPlanPlaceholder: vi.fn(() => false),
100
+ isImplementationPlaceholder: vi.fn(() => false),
101
+ parseReviewResult: vi.fn(() => ({ verdict: 'PASS', criticalIssues: [], minorIssues: [] })),
102
+ resolveTaskMaxReviewCycles: vi.fn((task, fallback = 3) => task?.maxReviewCycles || fallback),
103
+ reviewShouldPass: vi.fn(() => true),
104
+ }));
105
+
106
+ vi.mock('./sessionHistory.js', () => ({
107
+ createSessionEntry: vi.fn(() => ({ id: 'session-1' })),
108
+ }));
109
+
110
+ describe('createPR', () => {
111
+ beforeEach(() => {
112
+ vi.resetModules();
113
+ getTask.mockReset();
114
+ updateTask.mockReset();
115
+ appendLog.mockReset();
116
+ emit.mockReset();
117
+ fetchMock.mockReset();
118
+ checkoutMock.mockReset();
119
+ rebaseMock.mockReset();
120
+ rawMock.mockReset();
121
+ existsSyncMock.mockReset();
122
+ execFileSyncMock.mockReset();
123
+
124
+ getTask.mockReturnValue(task);
125
+ existsSyncMock.mockReturnValue(true);
126
+ execFileSyncMock.mockImplementation((cmd) => {
127
+ if (cmd === 'gh') {
128
+ const error = new Error('spawn gh ENOENT');
129
+ error.code = 'ENOENT';
130
+ error.path = 'gh';
131
+ throw error;
132
+ }
133
+ return '';
134
+ });
135
+ });
136
+
137
+ test('falls back to awaiting manual PR instead of blocking when gh is unavailable', async () => {
138
+ const { createPR } = await import('./orchestrator.js');
139
+
140
+ await createPR('T-42');
141
+
142
+ expect(fetchMock).toHaveBeenCalledWith('origin', 'main');
143
+ expect(checkoutMock).toHaveBeenCalledWith('feature/t-42-manual-pr');
144
+ expect(rebaseMock).toHaveBeenCalledWith(['origin/main']);
145
+ expect(rawMock).toHaveBeenCalledWith(['push', '--force-with-lease', 'origin', 'feature/t-42-manual-pr']);
146
+ expect(updateTask).toHaveBeenCalledWith('T-42', expect.objectContaining({
147
+ status: 'awaiting_manual_pr',
148
+ assignedTo: null,
149
+ blockedReason: expect.stringContaining('create the PR manually'),
150
+ }));
151
+ expect(emit).toHaveBeenCalledWith('task:manual-pr-required', expect.objectContaining({ taskId: 'T-42' }));
152
+ });
153
+
154
+ test('keeps non-gh ENOENT failures blocked instead of treating them as manual PR fallback', async () => {
155
+ fetchMock.mockRejectedValueOnce(Object.assign(new Error('spawn git ENOENT'), {
156
+ code: 'ENOENT',
157
+ path: 'git',
158
+ spawnargs: ['git', 'fetch'],
159
+ }));
160
+ execFileSyncMock.mockImplementation(() => '');
161
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
162
+
163
+ const { createPR } = await import('./orchestrator.js');
164
+
165
+ await createPR('T-42');
166
+
167
+ expect(updateTask).toHaveBeenCalledWith('T-42', expect.objectContaining({
168
+ status: 'blocked',
169
+ assignedTo: null,
170
+ blockedReason: expect.stringContaining('PR finalization failed'),
171
+ }));
172
+ expect(emit).toHaveBeenCalledWith('task:blocked', { taskId: 'T-42', reason: 'PR finalization failed' });
173
+ expect(emit).not.toHaveBeenCalledWith('task:manual-pr-required', expect.anything());
174
+
175
+ consoleError.mockRestore();
176
+ });
177
+ });