@stilero/bankan 1.0.21 → 1.1.2

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.2",
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)) {
@@ -1032,6 +1080,11 @@ async function createPR(taskId) {
1032
1080
  await git.fetch('origin', 'main');
1033
1081
  await git.checkout(task.branch);
1034
1082
 
1083
+ // Discard any uncommitted changes left by the agent (e.g. package-lock.json
1084
+ // from npm installs during review) so they don't block the rebase.
1085
+ await git.raw(['checkout', '--', '.']);
1086
+ await git.raw(['clean', '-fd']);
1087
+
1035
1088
  try {
1036
1089
  await git.rebase(['origin/main']);
1037
1090
  } catch (err) {
@@ -1040,6 +1093,12 @@ async function createPR(taskId) {
1040
1093
  }
1041
1094
 
1042
1095
  await git.raw(['push', '--force-with-lease', 'origin', task.branch]);
1096
+ const githubCapabilities = getGithubCapabilities();
1097
+ if (isManualPullRequestRequired(githubCapabilities)) {
1098
+ await transitionTaskToManualPr(taskId, githubCapabilities);
1099
+ return;
1100
+ }
1101
+
1043
1102
  const prBody = buildPullRequestBody(task);
1044
1103
  const prUrl = execFileSync('gh', [
1045
1104
  'pr', 'create',
@@ -1058,6 +1117,10 @@ async function createPR(taskId) {
1058
1117
  await cleanupWorkspace(store.getTask(taskId));
1059
1118
  store.updateTask(taskId, { status: 'done', assignedTo: null });
1060
1119
  } catch (err) {
1120
+ if (isManualPrAutomationError(err)) {
1121
+ await transitionTaskToManualPr(taskId);
1122
+ return;
1123
+ }
1061
1124
  console.error('PR creation error:', err.message);
1062
1125
  store.updateTask(taskId, {
1063
1126
  status: 'blocked',
@@ -1068,6 +1131,20 @@ async function createPR(taskId) {
1068
1131
  }
1069
1132
  }
1070
1133
 
1134
+ async function completeManualPr(taskId) {
1135
+ const task = store.getTask(taskId);
1136
+ if (!task || task.status !== 'awaiting_manual_pr') return;
1137
+
1138
+ await cleanupWorkspace(task);
1139
+ store.updateTask(taskId, {
1140
+ status: 'done',
1141
+ assignedTo: null,
1142
+ blockedReason: null,
1143
+ completedAt: new Date().toISOString(),
1144
+ });
1145
+ bus.emit('task:manual-pr-completed', { taskId });
1146
+ }
1147
+
1071
1148
  async function abortTask(taskId) {
1072
1149
  const task = store.getTask(taskId);
1073
1150
  if (!task || task.status === 'done') return;
@@ -1087,6 +1164,7 @@ async function abortTask(taskId) {
1087
1164
  reviewFeedback: null,
1088
1165
  previousStatus: null,
1089
1166
  reviewCycleCount: 0,
1167
+ maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1090
1168
  });
1091
1169
 
1092
1170
  bus.emit('task:aborted', { taskId });
@@ -1118,6 +1196,7 @@ async function resetTask(taskId) {
1118
1196
  planFeedback: null,
1119
1197
  previousStatus: null,
1120
1198
  reviewCycleCount: 0,
1199
+ maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1121
1200
  sessionHistory: [],
1122
1201
  progress: 0,
1123
1202
  totalTokens: 0,
@@ -1476,6 +1555,7 @@ const orchestrator = {
1476
1555
  abortTask,
1477
1556
  resetTask,
1478
1557
  deleteTask,
1558
+ completeManualPr,
1479
1559
  };
1480
1560
 
1481
1561
  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
+ });