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.
- package/dist/frontend/assets/index-B7wmLeyf.js +52 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server/branch-linker.js +3 -5
- package/dist/server/index.js +5 -20
- package/dist/server/integration-jira.js +103 -108
- package/dist/server/ticket-transitions.js +17 -129
- package/dist/test/integration-jira.test.js +133 -214
- package/dist/test/ticket-transitions.test.js +52 -257
- package/package.json +1 -1
- package/dist/frontend/assets/index-Dgf6cKGu.js +0 -52
- package/dist/server/integration-linear.js +0 -176
- package/dist/test/integration-linear.test.js +0 -293
|
@@ -146,32 +146,18 @@ describe('ticket-transitions', () => {
|
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
|
-
// ─── Jira
|
|
150
|
-
describe('ticket-transitions (Jira
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
179
|
+
function makeJiraApp(execOverride) {
|
|
195
180
|
const { exec } = makeExecMock();
|
|
196
|
-
const
|
|
181
|
+
const effectiveExec = execOverride ?? exec;
|
|
182
|
+
const deps = { configPath, execAsync: effectiveExec };
|
|
197
183
|
return createTicketTransitionsRouter(deps);
|
|
198
184
|
}
|
|
199
|
-
test('
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
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
|
-
|
|
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('
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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(
|
|
223
|
+
assert.equal(acliCalls.length, 0, 'Should not call acli when no status mapping exists');
|
|
271
224
|
});
|
|
272
|
-
test('
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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: '
|
|
234
|
+
ticketId: 'PROJ-55',
|
|
310
235
|
title: 'Jira test issue',
|
|
311
|
-
url: 'https://
|
|
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
|
-
|
|
318
|
-
assert.ok(
|
|
319
|
-
|
|
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(
|
|
246
|
+
assert.equal(acliCalls.length, firstCallCount, 'Second call should be blocked by idempotency guard after success');
|
|
324
247
|
});
|
|
325
|
-
test('
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
'
|
|
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
|
|
441
|
-
assert.ok(
|
|
442
|
-
assert.ok(
|
|
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
|
});
|