claude-remote-cli 3.10.0 → 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.
@@ -146,32 +146,18 @@ describe('ticket-transitions', () => {
146
146
  });
147
147
  });
148
148
  });
149
- // ─── Jira / Linear transition tests ─────────────────────────────────────────
150
- describe('ticket-transitions (Jira/Linear)', () => {
149
+ // ─── Jira transition tests ────────────────────────────────────────────────────
150
+ describe('ticket-transitions (Jira)', () => {
151
151
  let tmpDir;
152
152
  let configPath;
153
- const origFetch = globalThis.fetch;
154
- const origEnv = {};
155
153
  before(() => {
156
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-jira-linear-'));
154
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-jira-'));
157
155
  configPath = path.join(tmpDir, 'config.json');
158
- // Save env vars
159
- for (const key of ['JIRA_API_TOKEN', 'JIRA_EMAIL', 'JIRA_BASE_URL', 'LINEAR_API_KEY']) {
160
- origEnv[key] = process.env[key];
161
- }
162
156
  });
163
157
  after(() => {
164
- globalThis.fetch = origFetch;
165
- // Restore env vars
166
- for (const [key, val] of Object.entries(origEnv)) {
167
- if (val === undefined)
168
- delete process.env[key];
169
- else
170
- process.env[key] = val;
171
- }
172
158
  fs.rmSync(tmpDir, { recursive: true, force: true });
173
159
  });
174
- function writeConfig(mappings) {
160
+ function writeJiraConfig(statusMappings) {
175
161
  const config = {
176
162
  host: '0.0.0.0',
177
163
  port: 3456,
@@ -185,29 +171,25 @@ describe('ticket-transitions (Jira/Linear)', () => {
185
171
  launchInTmux: false,
186
172
  defaultNotifications: true,
187
173
  integrations: {
188
- ...(mappings.jira ? { jira: { projectKey: 'PROJ', statusMappings: mappings.jira } } : {}),
189
- ...(mappings.linear ? { linear: { teamId: 'TEAM', statusMappings: mappings.linear } } : {}),
174
+ jira: { projectKey: 'PROJ', statusMappings },
190
175
  },
191
176
  };
192
177
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
193
178
  }
194
- function makeJiraLinearApp() {
179
+ function makeJiraApp(execOverride) {
195
180
  const { exec } = makeExecMock();
196
- const deps = { configPath, execAsync: exec };
181
+ const effectiveExec = execOverride ?? exec;
182
+ const deps = { configPath, execAsync: effectiveExec };
197
183
  return createTicketTransitionsRouter(deps);
198
184
  }
199
- test('detectTicketSource returns jira for PROJ-123 when JIRA_API_TOKEN is set', async () => {
200
- process.env.JIRA_API_TOKEN = 'fake-token';
201
- process.env.JIRA_EMAIL = 'test@test.com';
202
- process.env.JIRA_BASE_URL = 'https://test.atlassian.net';
203
- delete process.env.LINEAR_API_KEY;
204
- writeConfig({ jira: { 'in-progress': '21' } });
205
- const { transitionOnSessionCreate } = makeJiraLinearApp();
206
- const fetchCalls = [];
207
- globalThis.fetch = (async (input) => {
208
- fetchCalls.push(String(input));
209
- return { ok: true, status: 200, json: async () => ({}) };
210
- });
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);
211
193
  const ctx = {
212
194
  ticketId: 'PROJ-123',
213
195
  title: 'Test',
@@ -217,47 +199,18 @@ describe('ticket-transitions (Jira/Linear)', () => {
217
199
  repoName: 'repo',
218
200
  };
219
201
  await transitionOnSessionCreate(ctx);
220
- assert.ok(fetchCalls.some((u) => u.includes('/rest/api/3/issue/PROJ-123/transitions')), `Expected Jira transition call, got: ${fetchCalls.join(', ')}`);
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');
221
205
  });
222
- test('detectTicketSource returns linear for TEAM-42 when LINEAR_API_KEY is set', async () => {
223
- delete process.env.JIRA_API_TOKEN;
224
- delete process.env.JIRA_EMAIL;
225
- delete process.env.JIRA_BASE_URL;
226
- process.env.LINEAR_API_KEY = 'fake-linear-key';
227
- writeConfig({ linear: { 'in-progress': 'state-in-progress-id' } });
228
- const { transitionOnSessionCreate } = makeJiraLinearApp();
229
- const fetchCalls = [];
230
- globalThis.fetch = (async (input) => {
231
- fetchCalls.push(String(input));
232
- return {
233
- ok: true,
234
- status: 200,
235
- json: async () => ({ data: { issues: { nodes: [{ id: 'issue-uuid' }] } } }),
236
- };
237
- });
238
- const ctx = {
239
- ticketId: 'TEAM-42',
240
- title: 'Test',
241
- url: 'https://linear.app/team/issue/TEAM-42',
242
- source: 'linear',
243
- repoPath: '/fake/repo',
244
- repoName: 'repo',
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: '' };
245
212
  };
246
- await transitionOnSessionCreate(ctx);
247
- assert.ok(fetchCalls.some((u) => u.includes('linear.app/graphql')), `Expected Linear GraphQL call, got: ${fetchCalls.join(', ')}`);
248
- });
249
- test('skips transition when no status mapping configured', async () => {
250
- process.env.JIRA_API_TOKEN = 'fake-token';
251
- process.env.JIRA_EMAIL = 'test@test.com';
252
- process.env.JIRA_BASE_URL = 'https://test.atlassian.net';
253
- // Config with empty mappings — no 'in-progress' mapping
254
- writeConfig({ jira: {} });
255
- const { transitionOnSessionCreate } = makeJiraLinearApp();
256
- const fetchCalls = [];
257
- globalThis.fetch = (async (input) => {
258
- fetchCalls.push(String(input));
259
- return { ok: true, status: 200, json: async () => ({}) };
260
- });
213
+ const { transitionOnSessionCreate } = makeJiraApp(trackingExec);
261
214
  const ctx = {
262
215
  ticketId: 'PROJ-456',
263
216
  title: 'Test',
@@ -267,204 +220,46 @@ describe('ticket-transitions (Jira/Linear)', () => {
267
220
  repoName: 'repo',
268
221
  };
269
222
  await transitionOnSessionCreate(ctx);
270
- assert.equal(fetchCalls.length, 0, 'Should not call fetch when no status mapping exists');
223
+ assert.equal(acliCalls.length, 0, 'Should not call acli when no status mapping exists');
271
224
  });
272
- test('checkPrTransitions calls Jira transition for OPEN PR with mapped ticket', async () => {
273
- process.env.JIRA_API_TOKEN = 'fake-token';
274
- process.env.JIRA_EMAIL = 'test@test.com';
275
- process.env.JIRA_BASE_URL = 'https://test.atlassian.net';
276
- delete process.env.LINEAR_API_KEY;
277
- writeConfig({ jira: { 'code-review': '31', 'ready-for-qa': '41' } });
278
- const { checkPrTransitions } = makeJiraLinearApp();
279
- const fetchCalls = [];
280
- globalThis.fetch = (async (input, init) => {
281
- const reqInit = init;
282
- fetchCalls.push({ url: String(input), body: reqInit?.body ?? '' });
283
- return { ok: true, status: 200, json: async () => ({}) };
284
- });
285
- const prs = [{ number: 10, headRefName: 'feat/jira-pr', state: 'OPEN' }];
286
- const branchLinks = {
287
- 'PROJ-789': [{ repoPath: '/fake/repo', repoName: 'repo', branchName: 'feat/jira-pr', hasActiveSession: true }],
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: '' };
288
231
  };
289
- await checkPrTransitions(prs, branchLinks);
290
- const transitionCall = fetchCalls.find((c) => c.url.includes('/transitions'));
291
- assert.ok(transitionCall, `Expected Jira transition call, got: ${fetchCalls.map((c) => c.url).join(', ')}`);
292
- assert.ok(transitionCall.body.includes('"31"'), 'Should use code-review transition ID 31');
293
- });
294
- // ── New tests ────────────────────────────────────────────────────────────
295
- test('Jira transitionOnSessionCreate — correct URL and transitionId, transitionMap updated only on success', async () => {
296
- process.env.JIRA_API_TOKEN = 'fake-token';
297
- process.env.JIRA_EMAIL = 'test@test.com';
298
- process.env.JIRA_BASE_URL = 'https://my-org.atlassian.net';
299
- delete process.env.LINEAR_API_KEY;
300
- writeConfig({ jira: { 'in-progress': '21', 'code-review': '31' } });
301
- const { transitionOnSessionCreate } = makeJiraLinearApp();
302
- const fetchCalls = [];
303
- globalThis.fetch = (async (input, init) => {
304
- const reqInit = init;
305
- fetchCalls.push({ url: String(input), body: reqInit?.body ?? '' });
306
- return { ok: true, status: 204, json: async () => ({}) };
307
- });
232
+ const { transitionOnSessionCreate } = makeJiraApp(trackingExec);
308
233
  const ctx = {
309
- ticketId: 'MYPROJ-55',
234
+ ticketId: 'PROJ-55',
310
235
  title: 'Jira test issue',
311
- url: 'https://my-org.atlassian.net/browse/MYPROJ-55',
236
+ url: 'https://jira.example.com/browse/PROJ-55',
312
237
  source: 'jira',
313
238
  repoPath: '/fake/repo',
314
239
  repoName: 'repo',
315
240
  };
316
241
  await transitionOnSessionCreate(ctx);
317
- assert.equal(fetchCalls.length, 1, 'Should make exactly one fetch call');
318
- assert.ok(fetchCalls[0].url.includes('/rest/api/3/issue/MYPROJ-55/transitions'), `Expected Jira transition URL, got: ${fetchCalls[0].url}`);
319
- assert.ok(fetchCalls[0].body.includes('"21"'), 'Should pass in-progress transition ID 21');
320
- // Verify idempotency — second call should be blocked because transitionMap was updated
321
- fetchCalls.length = 0;
242
+ const firstCallCount = acliCalls.length;
243
+ assert.ok(firstCallCount > 0, 'First call should trigger acli');
244
+ // Second call should be blocked by idempotency guard
322
245
  await transitionOnSessionCreate(ctx);
323
- assert.equal(fetchCalls.length, 0, 'Second call should be blocked by idempotency guard after success');
246
+ assert.equal(acliCalls.length, firstCallCount, 'Second call should be blocked by idempotency guard after success');
324
247
  });
325
- test('Linear transitionOnSessionCreate calls issue lookup then state update mutation', async () => {
326
- delete process.env.JIRA_API_TOKEN;
327
- delete process.env.JIRA_EMAIL;
328
- delete process.env.JIRA_BASE_URL;
329
- process.env.LINEAR_API_KEY = 'lin_api_fake';
330
- writeConfig({ linear: { 'in-progress': 'state-id-1', 'code-review': 'state-id-2' } });
331
- const { transitionOnSessionCreate } = makeJiraLinearApp();
332
- let callCount = 0;
333
- const fetchCalls = [];
334
- globalThis.fetch = (async (input, init) => {
335
- const reqInit = init;
336
- fetchCalls.push({ url: String(input), body: reqInit?.body ?? '' });
337
- callCount++;
338
- // First call: issue lookup — return issue UUID
339
- if (callCount === 1) {
340
- return {
341
- ok: true,
342
- status: 200,
343
- json: async () => ({ data: { issues: { nodes: [{ id: 'issue-uuid-abc' }] } } }),
344
- };
345
- }
346
- // Second call: state update mutation
347
- return {
348
- ok: true,
349
- status: 200,
350
- json: async () => ({ data: { issueUpdate: { success: true } } }),
351
- };
352
- });
353
- const ctx = {
354
- ticketId: 'ENG-77',
355
- title: 'Linear test issue',
356
- url: 'https://linear.app/eng/issue/ENG-77',
357
- source: 'linear',
358
- repoPath: '/fake/repo',
359
- repoName: 'repo',
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: '' };
360
254
  };
361
- await transitionOnSessionCreate(ctx);
362
- assert.equal(fetchCalls.length, 2, 'Should make two fetch calls — one lookup, one update');
363
- assert.ok(fetchCalls.every((c) => c.url.includes('linear.app/graphql')), 'Both calls should target the Linear GraphQL endpoint');
364
- // First call: issue lookup query
365
- assert.ok(fetchCalls[0].body.includes('ENG-77'), 'Lookup should reference the ticket identifier');
366
- // Second call: mutation with resolved issue UUID and target state ID
367
- assert.ok(fetchCalls[1].body.includes('issue-uuid-abc'), 'Update mutation should use resolved issue UUID');
368
- assert.ok(fetchCalls[1].body.includes('state-id-1'), 'Update mutation should pass in-progress state ID');
369
- // Verify idempotency after success
370
- fetchCalls.length = 0;
371
- callCount = 0;
372
- await transitionOnSessionCreate(ctx);
373
- assert.equal(fetchCalls.length, 0, 'Second call should be blocked by idempotency guard after success');
374
- });
375
- test('F5 premature idempotency — failed fetch does not update transitionMap, second call retries', async () => {
376
- process.env.JIRA_API_TOKEN = 'fake-token';
377
- process.env.JIRA_EMAIL = 'test@test.com';
378
- process.env.JIRA_BASE_URL = 'https://my-org.atlassian.net';
379
- delete process.env.LINEAR_API_KEY;
380
- writeConfig({ jira: { 'in-progress': '21', 'code-review': '31' } });
381
- const { transitionOnSessionCreate } = makeJiraLinearApp();
382
- const fetchCalls = [];
383
- // First attempt: server returns 500
384
- globalThis.fetch = (async (input) => {
385
- fetchCalls.push(String(input));
386
- return { ok: false, status: 500, json: async () => ({}) };
387
- });
388
- const ctx = {
389
- ticketId: 'FAIL-99',
390
- title: 'Failing ticket',
391
- url: 'https://my-org.atlassian.net/browse/FAIL-99',
392
- source: 'jira',
393
- repoPath: '/fake/repo',
394
- repoName: 'repo',
395
- };
396
- await transitionOnSessionCreate(ctx);
397
- assert.equal(fetchCalls.length, 1, 'First call should attempt fetch');
398
- // Second attempt after failure: transitionMap should NOT have been updated,
399
- // so the guard should not block this retry
400
- const fetchCallsBeforeRetry = fetchCalls.length;
401
- globalThis.fetch = (async (input) => {
402
- fetchCalls.push(String(input));
403
- return { ok: true, status: 204, json: async () => ({}) };
404
- });
405
- await transitionOnSessionCreate(ctx);
406
- assert.ok(fetchCalls.length > fetchCallsBeforeRetry, 'Second call should NOT be blocked — failed remote call must not update transitionMap');
407
- });
408
- test('Source detection via BranchLink source field — jira source overrides env-var heuristic', async () => {
409
- // Set up env so that env-var heuristic would pick linear (if source field were ignored)
410
- delete process.env.JIRA_API_TOKEN;
411
- delete process.env.JIRA_EMAIL;
412
- delete process.env.JIRA_BASE_URL;
413
- process.env.LINEAR_API_KEY = 'lin_api_fake';
414
- // But also set Jira env so jiraTransition can actually run
415
- process.env.JIRA_API_TOKEN = 'fake-token';
416
- process.env.JIRA_EMAIL = 'test@test.com';
417
- process.env.JIRA_BASE_URL = 'https://my-org.atlassian.net';
418
- writeConfig({ jira: { 'code-review': '31' }, linear: { 'code-review': 'state-id-2' } });
419
- const { checkPrTransitions } = makeJiraLinearApp();
420
- const fetchCalls = [];
421
- globalThis.fetch = (async (input, init) => {
422
- const reqInit = init;
423
- fetchCalls.push({ url: String(input), body: reqInit?.body ?? '' });
424
- return { ok: true, status: 200, json: async () => ({}) };
425
- });
426
- const prs = [{ number: 20, headRefName: 'feat/via-branch-link', state: 'OPEN' }];
427
- // BranchLink explicitly declares source: 'jira'
255
+ const { checkPrTransitions } = makeJiraApp(trackingExec);
256
+ const prs = [{ number: 10, headRefName: 'feat/jira-pr', state: 'OPEN' }];
428
257
  const branchLinks = {
429
- 'XPROJ-10': [
430
- {
431
- repoPath: '/fake/repo',
432
- repoName: 'repo',
433
- branchName: 'feat/via-branch-link',
434
- hasActiveSession: true,
435
- source: 'jira',
436
- },
437
- ],
258
+ 'PROJ-789': [{ repoPath: '/fake/repo', repoName: 'repo', branchName: 'feat/jira-pr', hasActiveSession: true }],
438
259
  };
439
260
  await checkPrTransitions(prs, branchLinks);
440
- const jiraCall = fetchCalls.find((c) => c.url.includes('/rest/api/3/issue/XPROJ-10/transitions'));
441
- assert.ok(jiraCall, `Expected Jira transition call (source from BranchLink), got: ${fetchCalls.map((c) => c.url).join(', ')}`);
442
- assert.ok(jiraCall.body.includes('"31"'), 'Should use code-review Jira transition ID');
443
- const linearCall = fetchCalls.find((c) => c.url.includes('linear.app/graphql'));
444
- assert.equal(linearCall, undefined, 'Should NOT call Linear when BranchLink.source is jira');
445
- });
446
- test('Jira URL validation — http non-localhost URL is rejected without making a fetch', async () => {
447
- process.env.JIRA_API_TOKEN = 'fake-token';
448
- process.env.JIRA_EMAIL = 'test@test.com';
449
- // Insecure non-localhost URL — should be rejected
450
- process.env.JIRA_BASE_URL = 'http://evil.com';
451
- delete process.env.LINEAR_API_KEY;
452
- writeConfig({ jira: { 'in-progress': '21' } });
453
- const { transitionOnSessionCreate } = makeJiraLinearApp();
454
- const fetchCalls = [];
455
- globalThis.fetch = (async (input) => {
456
- fetchCalls.push(String(input));
457
- return { ok: true, status: 200, json: async () => ({}) };
458
- });
459
- const ctx = {
460
- ticketId: 'EVIL-1',
461
- title: 'Evil ticket',
462
- url: 'http://evil.com/browse/EVIL-1',
463
- source: 'jira',
464
- repoPath: '/fake/repo',
465
- repoName: 'repo',
466
- };
467
- await transitionOnSessionCreate(ctx);
468
- assert.equal(fetchCalls.length, 0, 'Should not make any fetch call when JIRA_BASE_URL is http non-localhost');
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');
469
264
  });
470
265
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.10.0",
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",