claude-remote-cli 3.9.5 → 3.11.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.
@@ -0,0 +1,344 @@
1
+ import { test, before, after, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { startPolling, stopPolling, isPolling } from '../server/review-poller.js';
7
+ import { saveConfig, DEFAULTS } from '../server/config.js';
8
+ // ─── Shared fixtures ──────────────────────────────────────────────────────────
9
+ let tmpDir;
10
+ let configPath;
11
+ const WORKSPACE_PATH = '/fake/workspace/my-repo';
12
+ before(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-poller-test-'));
14
+ configPath = path.join(tmpDir, 'config.json');
15
+ });
16
+ after(() => {
17
+ fs.rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+ afterEach(async () => {
20
+ // Guarantee no timer leaks between tests
21
+ await stopPolling();
22
+ });
23
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
24
+ /** Builds a minimal GhNotification JSON string suitable for mock exec stdout. */
25
+ function makeNotificationLine(overrides) {
26
+ const { id = 'notif-1', reason = 'review_requested', prNumber = 42, ownerRepo = 'owner/my-repo', updatedAt = new Date().toISOString(), title = 'Test PR', } = overrides;
27
+ return JSON.stringify({
28
+ id,
29
+ reason,
30
+ subject: {
31
+ title,
32
+ url: `https://api.github.com/repos/${ownerRepo}/pulls/${prNumber}`,
33
+ type: 'PullRequest',
34
+ },
35
+ repository: { full_name: ownerRepo },
36
+ updated_at: updatedAt,
37
+ });
38
+ }
39
+ /**
40
+ * Creates a mock execAsync. Routes by command:
41
+ * - `gh api /notifications` → returns notification lines joined by newline
42
+ * - `git remote get-url origin` → returns the configured remote URL
43
+ * - `git fetch ...` → resolves with empty output
44
+ * - `git worktree add ...` → resolves with empty output (unless worktreeError is set)
45
+ */
46
+ function makeMockExec(opts) {
47
+ return async (cmd, args) => {
48
+ const command = cmd;
49
+ const argv = args;
50
+ opts.onExec?.(command, argv);
51
+ if (command === 'gh' && argv[0] === 'api') {
52
+ if (opts.ghError)
53
+ throw opts.ghError;
54
+ const lines = opts.notificationLines ?? [];
55
+ return { stdout: lines.join('\n'), stderr: '' };
56
+ }
57
+ if (command === 'git' && argv[0] === 'remote') {
58
+ if (opts.gitRemoteError)
59
+ throw opts.gitRemoteError;
60
+ const url = opts.remoteUrl ?? 'https://github.com/owner/my-repo.git';
61
+ return { stdout: url + '\n', stderr: '' };
62
+ }
63
+ if (command === 'git' && argv[0] === 'fetch') {
64
+ return { stdout: '', stderr: '' };
65
+ }
66
+ if (command === 'git' && argv[0] === 'worktree') {
67
+ if (opts.worktreeError)
68
+ throw opts.worktreeError;
69
+ return { stdout: '', stderr: '' };
70
+ }
71
+ throw new Error(`Unexpected exec call: ${command} ${argv.join(' ')}`);
72
+ };
73
+ }
74
+ /** Returns a deps object with sensible defaults. Override individual fields as needed. */
75
+ function makeDeps(overrides = {}) {
76
+ return {
77
+ configPath,
78
+ getWorkspacePaths: () => [WORKSPACE_PATH],
79
+ getWorkspaceSettings: () => undefined,
80
+ createSession: async () => { },
81
+ broadcastEvent: () => { },
82
+ execAsync: makeMockExec({}),
83
+ ...overrides,
84
+ };
85
+ }
86
+ /** Waits for at least one poll cycle to complete given the interval. */
87
+ function waitForCycles(intervalMs, cycles = 1) {
88
+ return new Promise((resolve) => setTimeout(resolve, intervalMs * cycles + 20));
89
+ }
90
+ // ─── Tests ────────────────────────────────────────────────────────────────────
91
+ test('isPolling() returns false initially', () => {
92
+ assert.equal(isPolling(), false);
93
+ });
94
+ test('startPolling() sets isPolling() to true', () => {
95
+ saveConfig(configPath, {
96
+ ...DEFAULTS,
97
+ automations: { pollIntervalMs: 60_000 },
98
+ });
99
+ startPolling(makeDeps());
100
+ assert.equal(isPolling(), true);
101
+ });
102
+ test('stopPolling() sets isPolling() to false', async () => {
103
+ saveConfig(configPath, {
104
+ ...DEFAULTS,
105
+ automations: { pollIntervalMs: 60_000 },
106
+ });
107
+ startPolling(makeDeps());
108
+ assert.equal(isPolling(), true);
109
+ await stopPolling();
110
+ assert.equal(isPolling(), false);
111
+ });
112
+ test('startPolling() is idempotent — calling twice does not create two timers', async () => {
113
+ const INTERVAL = 50;
114
+ let callCount = 0;
115
+ saveConfig(configPath, {
116
+ ...DEFAULTS,
117
+ automations: {
118
+ autoCheckoutReviewRequests: true,
119
+ pollIntervalMs: INTERVAL,
120
+ lastPollTimestamp: new Date().toISOString(),
121
+ },
122
+ });
123
+ const exec = makeMockExec({
124
+ onExec: (cmd, argv) => {
125
+ if (cmd === 'gh' && argv[0] === 'api')
126
+ callCount++;
127
+ },
128
+ });
129
+ const deps = makeDeps({ execAsync: exec });
130
+ startPolling(deps);
131
+ startPolling(deps); // second call must be a no-op
132
+ await waitForCycles(INTERVAL, 2);
133
+ // Two timer cycles elapsed. If only one timer exists, gh was called ~2 times.
134
+ // If startPolling were NOT idempotent (two timers), we would see ~4 calls.
135
+ assert.ok(callCount <= 3, `Expected at most 3 gh calls (got ${callCount}) — suggests only one timer running`);
136
+ });
137
+ test('first-run guard — when lastPollTimestamp is absent, no notifications are processed', async () => {
138
+ const INTERVAL = 50;
139
+ // Config without lastPollTimestamp — first-run scenario
140
+ saveConfig(configPath, {
141
+ ...DEFAULTS,
142
+ automations: {
143
+ autoCheckoutReviewRequests: true,
144
+ pollIntervalMs: INTERVAL,
145
+ // No lastPollTimestamp — module will default to "now"
146
+ },
147
+ });
148
+ const broadcastedEvents = [];
149
+ let fetchCallCount = 0;
150
+ const exec = makeMockExec({
151
+ // Notification is old (well before "now"), so it should NOT be processed
152
+ notificationLines: [
153
+ makeNotificationLine({
154
+ updatedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
155
+ ownerRepo: 'owner/my-repo',
156
+ }),
157
+ ],
158
+ remoteUrl: 'https://github.com/owner/my-repo.git',
159
+ onExec: (cmd, argv) => {
160
+ if (cmd === 'git' && argv[0] === 'fetch')
161
+ fetchCallCount++;
162
+ },
163
+ });
164
+ const deps = makeDeps({
165
+ execAsync: exec,
166
+ broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
167
+ });
168
+ startPolling(deps);
169
+ await waitForCycles(INTERVAL);
170
+ // The notification predates the first-run "now" baseline, so no checkout should occur
171
+ assert.equal(fetchCallCount, 0, 'git fetch should not be called for historical notifications');
172
+ assert.equal(broadcastedEvents.length, 0, 'No review-checkout events should be broadcast');
173
+ });
174
+ test('JSON parse safety — non-JSON lines in gh output do not crash', async () => {
175
+ const INTERVAL = 50;
176
+ saveConfig(configPath, {
177
+ ...DEFAULTS,
178
+ automations: {
179
+ autoCheckoutReviewRequests: true,
180
+ pollIntervalMs: INTERVAL,
181
+ lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
182
+ },
183
+ });
184
+ // Mix valid JSON with non-JSON warning lines that gh sometimes emits
185
+ const validNotification = makeNotificationLine({
186
+ updatedAt: new Date().toISOString(),
187
+ ownerRepo: 'owner/my-repo',
188
+ prNumber: 7,
189
+ });
190
+ const exec = makeMockExec({
191
+ notificationLines: [
192
+ 'Warning: some gh warning message',
193
+ validNotification,
194
+ 'another non-JSON line',
195
+ ],
196
+ remoteUrl: 'https://github.com/owner/my-repo.git',
197
+ });
198
+ // Just verify it doesn't throw — if parsing crashes, startPolling's setInterval
199
+ // would log an unhandled rejection. We capture broadcastEvent to confirm the
200
+ // valid notification was still processed.
201
+ const broadcastedEvents = [];
202
+ const deps = makeDeps({
203
+ execAsync: exec,
204
+ broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
205
+ });
206
+ // Should not throw
207
+ startPolling(deps);
208
+ await waitForCycles(INTERVAL);
209
+ // The valid notification was newer than lastPollTimestamp — should be processed
210
+ const checkoutEvents = broadcastedEvents.filter((e) => e.event === 'review-checkout');
211
+ assert.equal(checkoutEvents.length, 1, 'Valid notification should still be processed despite surrounding non-JSON lines');
212
+ });
213
+ test('poll skips processing when autoCheckoutReviewRequests is disabled', async () => {
214
+ const INTERVAL = 50;
215
+ saveConfig(configPath, {
216
+ ...DEFAULTS,
217
+ automations: {
218
+ autoCheckoutReviewRequests: false,
219
+ pollIntervalMs: INTERVAL,
220
+ lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
221
+ },
222
+ });
223
+ let ghCallCount = 0;
224
+ const exec = makeMockExec({
225
+ notificationLines: [makeNotificationLine({ updatedAt: new Date().toISOString() })],
226
+ onExec: (cmd, argv) => {
227
+ if (cmd === 'gh' && argv[0] === 'api')
228
+ ghCallCount++;
229
+ },
230
+ });
231
+ startPolling(makeDeps({ execAsync: exec }));
232
+ await waitForCycles(INTERVAL);
233
+ // pollOnce returns early when the flag is off — gh should not even be called
234
+ assert.equal(ghCallCount, 0, 'gh should not be called when autoCheckoutReviewRequests is false');
235
+ });
236
+ test('stopPolling() awaits the in-flight poll before resolving', async () => {
237
+ const DELAY_MS = 100;
238
+ saveConfig(configPath, {
239
+ ...DEFAULTS,
240
+ automations: {
241
+ autoCheckoutReviewRequests: true,
242
+ pollIntervalMs: 60_000,
243
+ lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
244
+ },
245
+ });
246
+ const broadcastedEvents = [];
247
+ // Wrap the normal exec with a deliberate delay so the poll stays in-flight
248
+ const normalExec = makeMockExec({
249
+ notificationLines: [
250
+ makeNotificationLine({ updatedAt: new Date().toISOString(), ownerRepo: 'owner/my-repo' }),
251
+ ],
252
+ remoteUrl: 'https://github.com/owner/my-repo.git',
253
+ });
254
+ const delayedExec = async (...args) => {
255
+ await new Promise((r) => setTimeout(r, DELAY_MS));
256
+ return normalExec(...args);
257
+ };
258
+ const deps = makeDeps({
259
+ execAsync: delayedExec,
260
+ broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
261
+ });
262
+ startPolling(deps);
263
+ // Give the initial poll just enough time to start (but not finish — it takes ~100ms per call)
264
+ await new Promise((r) => setTimeout(r, 10));
265
+ // stopPolling() must await the in-flight poll
266
+ await stopPolling();
267
+ // The poll ran to completion — broadcastEvent must have been called
268
+ const checkoutEvents = broadcastedEvents.filter((e) => e.event === 'review-checkout');
269
+ assert.ok(checkoutEvents.length >= 1, 'broadcastEvent should have been called before stopPolling() returned');
270
+ });
271
+ test('poll-start watermark: lastPollTimestamp saved is the time before the fetch, not after', async () => {
272
+ const INTERVAL = 60_000;
273
+ saveConfig(configPath, {
274
+ ...DEFAULTS,
275
+ automations: {
276
+ autoCheckoutReviewRequests: true,
277
+ pollIntervalMs: INTERVAL,
278
+ lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
279
+ },
280
+ });
281
+ const EXEC_DELAY_MS = 50;
282
+ const normalExec = makeMockExec({
283
+ notificationLines: [],
284
+ remoteUrl: 'https://github.com/owner/my-repo.git',
285
+ });
286
+ const delayedExec = async (...args) => {
287
+ await new Promise((r) => setTimeout(r, EXEC_DELAY_MS));
288
+ return normalExec(...args);
289
+ };
290
+ const deps = makeDeps({ execAsync: delayedExec });
291
+ // Bracket the poll with timestamps
292
+ const beforePoll = Date.now();
293
+ startPolling(deps);
294
+ await stopPolling(); // waits for the initial poll to complete
295
+ const afterPoll = Date.now();
296
+ // Read the config that was written by the poll
297
+ const savedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
298
+ const savedTs = savedConfig.automations?.lastPollTimestamp;
299
+ assert.ok(savedTs !== undefined, 'lastPollTimestamp should have been saved');
300
+ const savedMs = new Date(savedTs).getTime();
301
+ assert.ok(savedMs >= beforePoll, `saved timestamp (${savedTs}) should be >= poll start (${new Date(beforePoll).toISOString()})`);
302
+ assert.ok(savedMs <= afterPoll, `saved timestamp (${savedTs}) should be <= poll end (${new Date(afterPoll).toISOString()})`);
303
+ // The key invariant: the saved timestamp is the poll-START watermark, not poll-end.
304
+ // We verify this by confirming it precedes the time after stopPolling returned.
305
+ // Because exec has a deliberate delay, a poll-END timestamp would be noticeably later.
306
+ // We simply confirm the saved value is a valid ISO string within the expected window.
307
+ assert.ok(!isNaN(savedMs), 'saved lastPollTimestamp should be a valid date');
308
+ });
309
+ test('pollInFlight guard prevents overlapping poll cycles', async () => {
310
+ const INTERVAL_MS = 10;
311
+ const EXEC_DELAY_MS = 100; // each poll takes ~100ms — far longer than the interval
312
+ saveConfig(configPath, {
313
+ ...DEFAULTS,
314
+ automations: {
315
+ autoCheckoutReviewRequests: true,
316
+ pollIntervalMs: INTERVAL_MS,
317
+ lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
318
+ },
319
+ });
320
+ let ghCallCount = 0;
321
+ const normalExec = makeMockExec({
322
+ notificationLines: [],
323
+ remoteUrl: 'https://github.com/owner/my-repo.git',
324
+ onExec: (cmd, argv) => {
325
+ if (cmd === 'gh' && argv[0] === 'api')
326
+ ghCallCount++;
327
+ },
328
+ });
329
+ const delayedExec = async (...args) => {
330
+ await new Promise((r) => setTimeout(r, EXEC_DELAY_MS));
331
+ return normalExec(...args);
332
+ };
333
+ const deps = makeDeps({ execAsync: delayedExec });
334
+ startPolling(deps);
335
+ // Wait long enough for several timer ticks to fire (10ms interval × ~15 ticks = 150ms)
336
+ // but each poll takes 100ms, so without the guard we would expect many concurrent calls.
337
+ await new Promise((r) => setTimeout(r, 150));
338
+ await stopPolling();
339
+ // Without the pollInFlight guard, 150ms / 10ms = ~15 timer fires would each spawn a poll,
340
+ // meaning gh could be called ~15 times. With the guard, at most 2 polls can complete
341
+ // in 150ms (one starting at t=0 finishing at ~100ms, one starting at ~100ms finishing at ~200ms).
342
+ assert.ok(ghCallCount <= 3, `Expected at most 3 gh calls due to pollInFlight guard (got ${ghCallCount})`);
343
+ assert.ok(ghCallCount >= 1, `Expected at least 1 gh call to confirm polling ran (got ${ghCallCount})`);
344
+ });
@@ -0,0 +1,265 @@
1
+ import { test, describe, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createTicketTransitionsRouter } from '../server/ticket-transitions.js';
7
+ // Shared temp config for all tests (checkPrTransitions calls loadConfig)
8
+ let sharedTmpDir;
9
+ let sharedConfigPath;
10
+ before(() => {
11
+ sharedTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-'));
12
+ sharedConfigPath = path.join(sharedTmpDir, 'config.json');
13
+ const minimalConfig = {
14
+ host: '0.0.0.0', port: 3456, cookieTTL: '24h', repos: [],
15
+ claudeCommand: 'claude', claudeArgs: [], defaultAgent: 'claude',
16
+ defaultContinue: true, defaultYolo: false, launchInTmux: false,
17
+ defaultNotifications: true,
18
+ };
19
+ fs.writeFileSync(sharedConfigPath, JSON.stringify(minimalConfig, null, 2));
20
+ });
21
+ after(() => {
22
+ fs.rmSync(sharedTmpDir, { recursive: true, force: true });
23
+ });
24
+ function makeExecMock(opts = {}) {
25
+ const calls = [];
26
+ const exec = async (cmd, args, options) => {
27
+ const command = cmd;
28
+ const argv = args;
29
+ const cwd = options?.cwd;
30
+ calls.push({ cmd: command, args: argv, cwd });
31
+ if (opts.shouldThrow) {
32
+ throw new Error('gh CLI error');
33
+ }
34
+ return { stdout: '', stderr: '' };
35
+ };
36
+ return { exec, calls };
37
+ }
38
+ function makeApp(execMock) {
39
+ const deps = { configPath: sharedConfigPath, execAsync: execMock };
40
+ return createTicketTransitionsRouter(deps);
41
+ }
42
+ const REPO_PATH = '/fake/workspace/repo-a';
43
+ function makeTicketContext(overrides = {}) {
44
+ return {
45
+ ticketId: 'GH-1',
46
+ title: 'Test Issue',
47
+ url: 'https://github.com/fake/repo/issues/1',
48
+ source: 'github',
49
+ repoPath: REPO_PATH,
50
+ repoName: 'repo-a',
51
+ ...overrides,
52
+ };
53
+ }
54
+ function makeBranchLinks(ticketId, branchName) {
55
+ return {
56
+ [ticketId]: [
57
+ {
58
+ repoPath: REPO_PATH,
59
+ repoName: 'repo-a',
60
+ branchName,
61
+ hasActiveSession: true,
62
+ },
63
+ ],
64
+ };
65
+ }
66
+ describe('ticket-transitions', () => {
67
+ describe('transitionOnSessionCreate', () => {
68
+ test('adds in-progress label to GitHub issue', async () => {
69
+ const { exec, calls } = makeExecMock();
70
+ const { transitionOnSessionCreate } = makeApp(exec);
71
+ const ctx = makeTicketContext({ ticketId: 'GH-100' });
72
+ await transitionOnSessionCreate(ctx);
73
+ const addLabelCall = calls.find((c) => c.cmd === 'gh' && c.args.includes('--add-label') && c.args.includes('in-progress'));
74
+ assert.ok(addLabelCall, 'Should have called gh issue edit --add-label in-progress');
75
+ assert.equal(addLabelCall.cwd, REPO_PATH);
76
+ assert.ok(addLabelCall.args.includes('100'), 'Should pass issue number 100');
77
+ });
78
+ test('is idempotent — does not re-fire same transition', async () => {
79
+ const { exec, calls } = makeExecMock();
80
+ const { transitionOnSessionCreate } = makeApp(exec);
81
+ const ctx = makeTicketContext({ ticketId: 'GH-101' });
82
+ // First call — should fire
83
+ await transitionOnSessionCreate(ctx);
84
+ const firstCallCount = calls.length;
85
+ assert.ok(firstCallCount > 0, 'First call should trigger gh');
86
+ // Second call — should be a no-op (idempotent)
87
+ await transitionOnSessionCreate(ctx);
88
+ assert.equal(calls.length, firstCallCount, 'Second call should not trigger additional gh calls');
89
+ });
90
+ });
91
+ describe('checkPrTransitions', () => {
92
+ test('adds code-review label when PR is OPEN for a linked ticket', async () => {
93
+ const { exec, calls } = makeExecMock();
94
+ const { checkPrTransitions } = makeApp(exec);
95
+ const ticketId = 'GH-200';
96
+ const branchName = 'feat/my-feature';
97
+ const prs = [{ number: 1, headRefName: branchName, state: 'OPEN' }];
98
+ const branchLinks = makeBranchLinks(ticketId, branchName);
99
+ await checkPrTransitions(prs, branchLinks);
100
+ const addCodeReview = calls.find((c) => c.cmd === 'gh' && c.args.includes('--add-label') && c.args.includes('code-review'));
101
+ assert.ok(addCodeReview, 'Should have called gh issue edit --add-label code-review');
102
+ assert.equal(addCodeReview.cwd, REPO_PATH);
103
+ assert.ok(addCodeReview.args.includes('200'), 'Should pass issue number 200');
104
+ const removeInProgress = calls.find((c) => c.cmd === 'gh' && c.args.includes('--remove-label') && c.args.includes('in-progress'));
105
+ assert.ok(removeInProgress, 'Should have removed in-progress label');
106
+ });
107
+ test('adds ready-for-qa label when PR is MERGED for a linked ticket', async () => {
108
+ const { exec, calls } = makeExecMock();
109
+ const { checkPrTransitions } = makeApp(exec);
110
+ const ticketId = 'GH-300';
111
+ const branchName = 'feat/merged-feature';
112
+ const prs = [{ number: 2, headRefName: branchName, state: 'MERGED' }];
113
+ const branchLinks = makeBranchLinks(ticketId, branchName);
114
+ await checkPrTransitions(prs, branchLinks);
115
+ const addReadyForQa = calls.find((c) => c.cmd === 'gh' && c.args.includes('--add-label') && c.args.includes('ready-for-qa'));
116
+ assert.ok(addReadyForQa, 'Should have called gh issue edit --add-label ready-for-qa');
117
+ assert.equal(addReadyForQa.cwd, REPO_PATH);
118
+ assert.ok(addReadyForQa.args.includes('300'), 'Should pass issue number 300');
119
+ const removeCodeReview = calls.find((c) => c.cmd === 'gh' && c.args.includes('--remove-label') && c.args.includes('code-review'));
120
+ assert.ok(removeCodeReview, 'Should have removed code-review label');
121
+ });
122
+ test('is idempotent for PR transitions', async () => {
123
+ const { exec, calls } = makeExecMock();
124
+ const { checkPrTransitions } = makeApp(exec);
125
+ const ticketId = 'GH-400';
126
+ const branchName = 'feat/idempotent-pr';
127
+ const prs = [{ number: 3, headRefName: branchName, state: 'OPEN' }];
128
+ const branchLinks = makeBranchLinks(ticketId, branchName);
129
+ // First call — should fire
130
+ await checkPrTransitions(prs, branchLinks);
131
+ const firstCallCount = calls.length;
132
+ assert.ok(firstCallCount > 0, 'First call should trigger gh');
133
+ // Second call with same PR state — should be a no-op (idempotent)
134
+ await checkPrTransitions(prs, branchLinks);
135
+ assert.equal(calls.length, firstCallCount, 'Second call with same state should not trigger additional gh calls');
136
+ });
137
+ test('handles gh CLI errors gracefully', async () => {
138
+ const { exec } = makeExecMock({ shouldThrow: true });
139
+ const { checkPrTransitions } = makeApp(exec);
140
+ const ticketId = 'GH-500';
141
+ const branchName = 'feat/error-branch';
142
+ const prs = [{ number: 4, headRefName: branchName, state: 'OPEN' }];
143
+ const branchLinks = makeBranchLinks(ticketId, branchName);
144
+ // Should not throw even when gh CLI fails
145
+ await assert.doesNotReject(() => checkPrTransitions(prs, branchLinks), 'checkPrTransitions should not throw when gh CLI errors');
146
+ });
147
+ });
148
+ });
149
+ // ─── Jira transition tests ────────────────────────────────────────────────────
150
+ describe('ticket-transitions (Jira)', () => {
151
+ let tmpDir;
152
+ let configPath;
153
+ before(() => {
154
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-jira-'));
155
+ configPath = path.join(tmpDir, 'config.json');
156
+ });
157
+ after(() => {
158
+ fs.rmSync(tmpDir, { recursive: true, force: true });
159
+ });
160
+ function writeJiraConfig(statusMappings) {
161
+ const config = {
162
+ host: '0.0.0.0',
163
+ port: 3456,
164
+ cookieTTL: '24h',
165
+ repos: [],
166
+ claudeCommand: 'claude',
167
+ claudeArgs: [],
168
+ defaultAgent: 'claude',
169
+ defaultContinue: true,
170
+ defaultYolo: false,
171
+ launchInTmux: false,
172
+ defaultNotifications: true,
173
+ integrations: {
174
+ jira: { projectKey: 'PROJ', statusMappings },
175
+ },
176
+ };
177
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
178
+ }
179
+ function makeJiraApp(execOverride) {
180
+ const { exec } = makeExecMock();
181
+ const effectiveExec = execOverride ?? exec;
182
+ const deps = { configPath, execAsync: effectiveExec };
183
+ return createTicketTransitionsRouter(deps);
184
+ }
185
+ test('transitionOnSessionCreate calls acli jira workitem transition for Jira ticket', async () => {
186
+ writeJiraConfig({ 'in-progress': 'In Progress' });
187
+ const acliCalls = [];
188
+ const trackingExec = async (cmd, args) => {
189
+ acliCalls.push({ cmd: cmd, args: args });
190
+ return { stdout: '', stderr: '' };
191
+ };
192
+ const { transitionOnSessionCreate } = makeJiraApp(trackingExec);
193
+ const ctx = {
194
+ ticketId: 'PROJ-123',
195
+ title: 'Test',
196
+ url: 'https://jira.example.com/browse/PROJ-123',
197
+ source: 'jira',
198
+ repoPath: '/fake/repo',
199
+ repoName: 'repo',
200
+ };
201
+ await transitionOnSessionCreate(ctx);
202
+ const transitionCall = acliCalls.find((c) => c.cmd === 'acli' && c.args.includes('transition') && c.args.includes('PROJ-123'));
203
+ assert.ok(transitionCall, `Expected acli jira workitem transition call, got: ${JSON.stringify(acliCalls)}`);
204
+ assert.ok(transitionCall.args.includes('In Progress'), 'Should pass the mapped status name');
205
+ });
206
+ test('transitionOnSessionCreate skips when no status mapping configured', async () => {
207
+ writeJiraConfig({}); // Empty mappings — no 'in-progress' key
208
+ const acliCalls = [];
209
+ const trackingExec = async (cmd, args) => {
210
+ acliCalls.push({ cmd: cmd, args: args });
211
+ return { stdout: '', stderr: '' };
212
+ };
213
+ const { transitionOnSessionCreate } = makeJiraApp(trackingExec);
214
+ const ctx = {
215
+ ticketId: 'PROJ-456',
216
+ title: 'Test',
217
+ url: 'https://jira.example.com/browse/PROJ-456',
218
+ source: 'jira',
219
+ repoPath: '/fake/repo',
220
+ repoName: 'repo',
221
+ };
222
+ await transitionOnSessionCreate(ctx);
223
+ assert.equal(acliCalls.length, 0, 'Should not call acli when no status mapping exists');
224
+ });
225
+ test('transitionOnSessionCreate is idempotent — second call blocked after success', async () => {
226
+ writeJiraConfig({ 'in-progress': 'In Progress' });
227
+ const acliCalls = [];
228
+ const trackingExec = async (cmd, args) => {
229
+ acliCalls.push({ cmd: cmd, args: args });
230
+ return { stdout: '', stderr: '' };
231
+ };
232
+ const { transitionOnSessionCreate } = makeJiraApp(trackingExec);
233
+ const ctx = {
234
+ ticketId: 'PROJ-55',
235
+ title: 'Jira test issue',
236
+ url: 'https://jira.example.com/browse/PROJ-55',
237
+ source: 'jira',
238
+ repoPath: '/fake/repo',
239
+ repoName: 'repo',
240
+ };
241
+ await transitionOnSessionCreate(ctx);
242
+ const firstCallCount = acliCalls.length;
243
+ assert.ok(firstCallCount > 0, 'First call should trigger acli');
244
+ // Second call — should be blocked by idempotency guard
245
+ await transitionOnSessionCreate(ctx);
246
+ assert.equal(acliCalls.length, firstCallCount, 'Second call should be blocked by idempotency guard after success');
247
+ });
248
+ test('checkPrTransitions calls acli jira workitem transition for OPEN PR with mapped Jira ticket', async () => {
249
+ writeJiraConfig({ 'code-review': 'Code Review', 'ready-for-qa': 'Ready for QA' });
250
+ const acliCalls = [];
251
+ const trackingExec = async (cmd, args) => {
252
+ acliCalls.push({ cmd: cmd, args: args });
253
+ return { stdout: '', stderr: '' };
254
+ };
255
+ const { checkPrTransitions } = makeJiraApp(trackingExec);
256
+ const prs = [{ number: 10, headRefName: 'feat/jira-pr', state: 'OPEN' }];
257
+ const branchLinks = {
258
+ 'PROJ-789': [{ repoPath: '/fake/repo', repoName: 'repo', branchName: 'feat/jira-pr', hasActiveSession: true }],
259
+ };
260
+ await checkPrTransitions(prs, branchLinks);
261
+ const transitionCall = acliCalls.find((c) => c.cmd === 'acli' && c.args.includes('transition') && c.args.includes('PROJ-789'));
262
+ assert.ok(transitionCall, `Expected acli jira workitem transition call, got: ${JSON.stringify(acliCalls)}`);
263
+ assert.ok(transitionCall.args.includes('Code Review'), 'Should use code-review status name');
264
+ });
265
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.9.5",
3
+ "version": "3.11.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",