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.
- package/dist/frontend/assets/index-B7wmLeyf.js +52 -0
- package/dist/frontend/assets/index-BTOnhJQN.css +32 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/branch-linker.js +134 -0
- package/dist/server/config.js +31 -1
- package/dist/server/index.js +186 -2
- package/dist/server/integration-github.js +117 -0
- package/dist/server/integration-jira.js +172 -0
- package/dist/server/org-dashboard.js +222 -0
- package/dist/server/review-poller.js +241 -0
- package/dist/server/sessions.js +43 -3
- package/dist/server/ticket-transitions.js +153 -0
- package/dist/test/branch-linker.test.js +231 -0
- package/dist/test/config.test.js +56 -0
- package/dist/test/integration-github.test.js +203 -0
- package/dist/test/integration-jira.test.js +221 -0
- package/dist/test/org-dashboard.test.js +240 -0
- package/dist/test/review-poller.test.js +344 -0
- package/dist/test/ticket-transitions.test.js +265 -0
- package/package.json +1 -1
- package/dist/frontend/assets/index-BYv7-2w9.css +0 -32
- package/dist/frontend/assets/index-CO9tRKXI.js +0 -52
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { createIntegrationJiraRouter } from '../server/integration-jira.js';
|
|
5
|
+
// ─── Mock stdout constants ────────────────────────────────────────────────────
|
|
6
|
+
const AUTH_STATUS_STDOUT = `✓ Authenticated
|
|
7
|
+
Site: fake-jira.atlassian.net
|
|
8
|
+
Email: test@example.com
|
|
9
|
+
Authentication Type: oauth
|
|
10
|
+
`;
|
|
11
|
+
// ─── Globals ──────────────────────────────────────────────────────────────────
|
|
12
|
+
let server;
|
|
13
|
+
let baseUrl;
|
|
14
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Builds a minimal AcliWorkItem for use in mock stdout payloads.
|
|
17
|
+
*/
|
|
18
|
+
function makeAcliWorkItem(overrides) {
|
|
19
|
+
const { key = 'TEST-1', summary = 'Test issue', statusId = '3', statusName = 'In Progress', priorityName = 'Medium', assigneeDisplayName = 'Jane Doe', } = overrides;
|
|
20
|
+
return {
|
|
21
|
+
key,
|
|
22
|
+
fields: {
|
|
23
|
+
summary,
|
|
24
|
+
status: { id: statusId, name: statusName },
|
|
25
|
+
...(priorityName !== null ? { priority: { name: priorityName } } : { priority: null }),
|
|
26
|
+
...(assigneeDisplayName !== null ? { assignee: { displayName: assigneeDisplayName } } : { assignee: null }),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a mock execAsync that routes calls based on the acli subcommand args:
|
|
32
|
+
* - `acli jira auth status` → returns AUTH_STATUS_STDOUT (or throws)
|
|
33
|
+
* - `acli jira workitem search` → returns the provided items as JSON stdout (or throws)
|
|
34
|
+
*/
|
|
35
|
+
function makeMockExec(opts) {
|
|
36
|
+
return async (_cmd, args) => {
|
|
37
|
+
const argv = args;
|
|
38
|
+
if (argv.includes('auth') && argv.includes('status')) {
|
|
39
|
+
if (opts.authError)
|
|
40
|
+
throw opts.authError;
|
|
41
|
+
return { stdout: AUTH_STATUS_STDOUT, stderr: '' };
|
|
42
|
+
}
|
|
43
|
+
if (argv.includes('workitem') && argv.includes('search')) {
|
|
44
|
+
if (opts.searchError)
|
|
45
|
+
throw opts.searchError;
|
|
46
|
+
return { stdout: JSON.stringify(opts.searchItems ?? []), stderr: '' };
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Unexpected exec call: acli ${argv.join(' ')}`);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ─── Server lifecycle ─────────────────────────────────────────────────────────
|
|
52
|
+
function startServer(execAsyncFn) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
const deps = { configPath: '', execAsync: execAsyncFn };
|
|
57
|
+
app.use('/integration-jira', createIntegrationJiraRouter(deps));
|
|
58
|
+
server = app.listen(0, '127.0.0.1', () => {
|
|
59
|
+
const addr = server.address();
|
|
60
|
+
if (typeof addr === 'object' && addr) {
|
|
61
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
62
|
+
}
|
|
63
|
+
resolve();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function stopServer() {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
if (server)
|
|
70
|
+
server.close(() => resolve());
|
|
71
|
+
else
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// ─── Suite teardown ───────────────────────────────────────────────────────────
|
|
76
|
+
after(async () => {
|
|
77
|
+
await stopServer();
|
|
78
|
+
});
|
|
79
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
80
|
+
test('GET /issues returns mapped JiraIssue[] from mocked acli JSON output', async () => {
|
|
81
|
+
await stopServer();
|
|
82
|
+
const searchItems = [
|
|
83
|
+
makeAcliWorkItem({
|
|
84
|
+
key: 'PROJ-42',
|
|
85
|
+
summary: 'Fix the login bug',
|
|
86
|
+
statusId: '3',
|
|
87
|
+
statusName: 'In Progress',
|
|
88
|
+
priorityName: 'High',
|
|
89
|
+
assigneeDisplayName: 'Alice',
|
|
90
|
+
}),
|
|
91
|
+
makeAcliWorkItem({
|
|
92
|
+
key: 'PROJ-10',
|
|
93
|
+
summary: 'Update docs',
|
|
94
|
+
statusId: '1',
|
|
95
|
+
statusName: 'To Do',
|
|
96
|
+
priorityName: null,
|
|
97
|
+
assigneeDisplayName: null,
|
|
98
|
+
}),
|
|
99
|
+
];
|
|
100
|
+
const exec = makeMockExec({ searchItems });
|
|
101
|
+
await startServer(exec);
|
|
102
|
+
const res = await fetch(`${baseUrl}/integration-jira/issues`);
|
|
103
|
+
assert.equal(res.status, 200);
|
|
104
|
+
const data = (await res.json());
|
|
105
|
+
assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
|
|
106
|
+
assert.equal(data.issues.length, 2);
|
|
107
|
+
const issue42 = data.issues.find((i) => i.key === 'PROJ-42');
|
|
108
|
+
assert.ok(issue42, 'Should contain PROJ-42');
|
|
109
|
+
assert.equal(issue42.title, 'Fix the login bug');
|
|
110
|
+
assert.equal(issue42.status, 'In Progress');
|
|
111
|
+
assert.equal(issue42.priority, 'High');
|
|
112
|
+
assert.equal(issue42.assignee, 'Alice');
|
|
113
|
+
assert.equal(issue42.url, 'https://fake-jira.atlassian.net/browse/PROJ-42');
|
|
114
|
+
assert.equal(issue42.projectKey, 'PROJ');
|
|
115
|
+
assert.equal(issue42.updatedAt, '');
|
|
116
|
+
assert.equal(issue42.sprint, null);
|
|
117
|
+
assert.equal(issue42.storyPoints, null);
|
|
118
|
+
const issue10 = data.issues.find((i) => i.key === 'PROJ-10');
|
|
119
|
+
assert.ok(issue10, 'Should contain PROJ-10');
|
|
120
|
+
assert.equal(issue10.priority, null);
|
|
121
|
+
assert.equal(issue10.assignee, null);
|
|
122
|
+
assert.equal(issue10.url, 'https://fake-jira.atlassian.net/browse/PROJ-10');
|
|
123
|
+
assert.equal(issue10.projectKey, 'PROJ');
|
|
124
|
+
});
|
|
125
|
+
test('GET /issues returns acli_not_in_path when exec throws ENOENT', async () => {
|
|
126
|
+
await stopServer();
|
|
127
|
+
const err = new Error('spawn acli ENOENT');
|
|
128
|
+
err.code = 'ENOENT';
|
|
129
|
+
const exec = makeMockExec({ authError: err });
|
|
130
|
+
await startServer(exec);
|
|
131
|
+
const res = await fetch(`${baseUrl}/integration-jira/issues`);
|
|
132
|
+
assert.equal(res.status, 200);
|
|
133
|
+
const data = (await res.json());
|
|
134
|
+
assert.equal(data.error, 'acli_not_in_path');
|
|
135
|
+
assert.equal(data.issues.length, 0);
|
|
136
|
+
});
|
|
137
|
+
test('GET /issues returns acli_not_authenticated when exec stderr contains auth message', async () => {
|
|
138
|
+
await stopServer();
|
|
139
|
+
const err = Object.assign(new Error('acli auth error'), {
|
|
140
|
+
stderr: "not logged in, use 'acli jira auth login' to authenticate",
|
|
141
|
+
});
|
|
142
|
+
const exec = makeMockExec({ authError: err });
|
|
143
|
+
await startServer(exec);
|
|
144
|
+
const res = await fetch(`${baseUrl}/integration-jira/issues`);
|
|
145
|
+
assert.equal(res.status, 200);
|
|
146
|
+
const data = (await res.json());
|
|
147
|
+
assert.equal(data.error, 'acli_not_authenticated');
|
|
148
|
+
assert.equal(data.issues.length, 0);
|
|
149
|
+
});
|
|
150
|
+
test('GET /issues caches results within TTL — exec called only once for two requests', async () => {
|
|
151
|
+
await stopServer();
|
|
152
|
+
let searchCallCount = 0;
|
|
153
|
+
const baseExec = makeMockExec({
|
|
154
|
+
searchItems: [makeAcliWorkItem({ key: 'CACHE-1', summary: 'Cached issue' })],
|
|
155
|
+
});
|
|
156
|
+
const countingExec = async (...args) => {
|
|
157
|
+
const argv = args[1];
|
|
158
|
+
if (argv.includes('workitem') && argv.includes('search')) {
|
|
159
|
+
searchCallCount++;
|
|
160
|
+
}
|
|
161
|
+
return baseExec(...args);
|
|
162
|
+
};
|
|
163
|
+
await startServer(countingExec);
|
|
164
|
+
// First request — populates cache
|
|
165
|
+
const firstRes = await fetch(`${baseUrl}/integration-jira/issues`);
|
|
166
|
+
const first = (await firstRes.json());
|
|
167
|
+
assert.equal(first.error, undefined, `Unexpected error: ${first.error}`);
|
|
168
|
+
assert.equal(first.issues.length, 1);
|
|
169
|
+
assert.equal(searchCallCount, 1, 'search should be called once on first request');
|
|
170
|
+
// Second request — should be served from cache, no additional exec call
|
|
171
|
+
const secondRes = await fetch(`${baseUrl}/integration-jira/issues`);
|
|
172
|
+
const second = (await secondRes.json());
|
|
173
|
+
assert.equal(second.error, undefined, `Unexpected error: ${second.error}`);
|
|
174
|
+
assert.equal(second.issues.length, 1);
|
|
175
|
+
assert.equal(searchCallCount, 1, 'search should not be called again within TTL (cache hit)');
|
|
176
|
+
});
|
|
177
|
+
test('GET /statuses?projectKey=TEST returns deduplicated statuses', async () => {
|
|
178
|
+
await stopServer();
|
|
179
|
+
// Items with overlapping status IDs — deduplication should collapse to 3 unique statuses
|
|
180
|
+
const searchItems = [
|
|
181
|
+
makeAcliWorkItem({ key: 'TEST-1', statusId: '1', statusName: 'To Do' }),
|
|
182
|
+
makeAcliWorkItem({ key: 'TEST-2', statusId: '2', statusName: 'In Progress' }),
|
|
183
|
+
makeAcliWorkItem({ key: 'TEST-3', statusId: '2', statusName: 'In Progress' }), // duplicate
|
|
184
|
+
makeAcliWorkItem({ key: 'TEST-4', statusId: '3', statusName: 'Done' }),
|
|
185
|
+
];
|
|
186
|
+
const exec = makeMockExec({ searchItems });
|
|
187
|
+
await startServer(exec);
|
|
188
|
+
const res = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
|
|
189
|
+
assert.equal(res.status, 200);
|
|
190
|
+
const data = (await res.json());
|
|
191
|
+
assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
|
|
192
|
+
assert.equal(data.statuses.length, 3, 'Should deduplicate statuses by id');
|
|
193
|
+
const ids = data.statuses.map((s) => s.id);
|
|
194
|
+
assert.deepEqual(ids, ['1', '2', '3']);
|
|
195
|
+
const names = data.statuses.map((s) => s.name);
|
|
196
|
+
assert.deepEqual(names, ['To Do', 'In Progress', 'Done']);
|
|
197
|
+
});
|
|
198
|
+
test('GET /statuses returns 400 when projectKey query param is missing', async () => {
|
|
199
|
+
await stopServer();
|
|
200
|
+
const exec = makeMockExec({ searchItems: [] });
|
|
201
|
+
await startServer(exec);
|
|
202
|
+
const res = await fetch(`${baseUrl}/integration-jira/statuses`);
|
|
203
|
+
assert.equal(res.status, 400);
|
|
204
|
+
const data = (await res.json());
|
|
205
|
+
assert.equal(data.error, 'missing_project_key');
|
|
206
|
+
});
|
|
207
|
+
test('GET /statuses returns 400 for invalid projectKey (lowercase or special chars)', async () => {
|
|
208
|
+
await stopServer();
|
|
209
|
+
const exec = makeMockExec({ searchItems: [] });
|
|
210
|
+
await startServer(exec);
|
|
211
|
+
// Lowercase key
|
|
212
|
+
const resLower = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=test`);
|
|
213
|
+
assert.equal(resLower.status, 400);
|
|
214
|
+
const dataLower = (await resLower.json());
|
|
215
|
+
assert.equal(dataLower.error, 'invalid_project_key');
|
|
216
|
+
// Key with special characters
|
|
217
|
+
const resSpecial = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST%20PROJ`);
|
|
218
|
+
assert.equal(resSpecial.status, 400);
|
|
219
|
+
const dataSpecial = (await resSpecial.json());
|
|
220
|
+
assert.equal(dataSpecial.error, 'invalid_project_key');
|
|
221
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { test, before, after } 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 express from 'express';
|
|
7
|
+
import { createOrgDashboardRouter } from '../server/org-dashboard.js';
|
|
8
|
+
import { saveConfig, DEFAULTS } from '../server/config.js';
|
|
9
|
+
let tmpDir;
|
|
10
|
+
let configPath;
|
|
11
|
+
let server;
|
|
12
|
+
let baseUrl;
|
|
13
|
+
// A workspace path we can point git remote mocks at
|
|
14
|
+
const WORKSPACE_PATH_A = '/fake/workspace/repo-a';
|
|
15
|
+
const WORKSPACE_PATH_B = '/fake/workspace/repo-b';
|
|
16
|
+
/**
|
|
17
|
+
* Creates a mock execAsync that routes calls based on the command.
|
|
18
|
+
* - `git remote get-url origin` → returns configured remote URL or throws
|
|
19
|
+
* - `gh api user ...` → returns configured user login or throws
|
|
20
|
+
* - `gh api search/issues ...` → returns configured search response or throws
|
|
21
|
+
*/
|
|
22
|
+
function makeMockExec(opts) {
|
|
23
|
+
return async (cmd, args, options) => {
|
|
24
|
+
const command = cmd;
|
|
25
|
+
const argv = args;
|
|
26
|
+
if (command === 'git' && argv[0] === 'remote') {
|
|
27
|
+
const cwd = options.cwd ?? '';
|
|
28
|
+
const remote = opts.remotes?.[cwd];
|
|
29
|
+
if (remote)
|
|
30
|
+
return { stdout: remote + '\n', stderr: '' };
|
|
31
|
+
throw Object.assign(new Error('not a git repository'), { code: 128 });
|
|
32
|
+
}
|
|
33
|
+
if (command === 'gh' && argv[0] === 'api' && argv[1] === 'user') {
|
|
34
|
+
if (opts.userError)
|
|
35
|
+
throw opts.userError;
|
|
36
|
+
const login = opts.userLogin ?? 'testuser';
|
|
37
|
+
return { stdout: login + '\n', stderr: '' };
|
|
38
|
+
}
|
|
39
|
+
if (command === 'gh' && argv[0] === 'api' && argv[1]?.startsWith('search/issues')) {
|
|
40
|
+
if (opts.searchError)
|
|
41
|
+
throw opts.searchError;
|
|
42
|
+
const items = opts.searchItems ?? [];
|
|
43
|
+
return { stdout: JSON.stringify({ items }), stderr: '' };
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unexpected exec call: ${command} ${argv.join(' ')}`);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds a minimal GH search item for a given owner/repo and user.
|
|
50
|
+
*/
|
|
51
|
+
function makeSearchItem(overrides) {
|
|
52
|
+
const { ownerRepo, number = 1, title = 'Test PR', author = 'testuser', role = 'author', currentUser = 'testuser', } = overrides;
|
|
53
|
+
return {
|
|
54
|
+
number,
|
|
55
|
+
title,
|
|
56
|
+
html_url: `https://github.com/${ownerRepo}/pull/${number}`,
|
|
57
|
+
state: 'open',
|
|
58
|
+
user: { login: author },
|
|
59
|
+
pull_request: { head: { ref: 'feat/branch' }, base: { ref: 'main' } },
|
|
60
|
+
updated_at: '2026-03-21T00:00:00Z',
|
|
61
|
+
requested_reviewers: role === 'reviewer' ? [{ login: currentUser }] : [],
|
|
62
|
+
repository_url: `https://api.github.com/repos/${ownerRepo}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function startServer(execAsyncFn) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const app = express();
|
|
68
|
+
app.use(express.json());
|
|
69
|
+
// Cast through unknown: the mock satisfies the runtime contract but the
|
|
70
|
+
// overloaded promisify types don't align across module instances.
|
|
71
|
+
const deps = { configPath, execAsync: execAsyncFn };
|
|
72
|
+
app.use('/org-dashboard', createOrgDashboardRouter(deps));
|
|
73
|
+
server = app.listen(0, '127.0.0.1', () => {
|
|
74
|
+
const addr = server.address();
|
|
75
|
+
if (typeof addr === 'object' && addr) {
|
|
76
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
77
|
+
}
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function stopServer() {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
if (server)
|
|
85
|
+
server.close(() => resolve());
|
|
86
|
+
else
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function getPrs() {
|
|
91
|
+
const res = await fetch(`${baseUrl}/org-dashboard/prs`);
|
|
92
|
+
return res.json();
|
|
93
|
+
}
|
|
94
|
+
before(() => {
|
|
95
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'org-dashboard-test-'));
|
|
96
|
+
configPath = path.join(tmpDir, 'config.json');
|
|
97
|
+
});
|
|
98
|
+
after(async () => {
|
|
99
|
+
await stopServer();
|
|
100
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
// Each test gets a fresh server with its own router (and thus its own cache).
|
|
103
|
+
// We stop/start the server around each test to reset the in-router cache state.
|
|
104
|
+
test('returns prs filtered to workspace repos', async () => {
|
|
105
|
+
await stopServer();
|
|
106
|
+
saveConfig(configPath, {
|
|
107
|
+
...DEFAULTS,
|
|
108
|
+
workspaces: [WORKSPACE_PATH_A, WORKSPACE_PATH_B],
|
|
109
|
+
});
|
|
110
|
+
const exec = makeMockExec({
|
|
111
|
+
remotes: {
|
|
112
|
+
[WORKSPACE_PATH_A]: 'git@github.com:myorg/repo-a.git',
|
|
113
|
+
[WORKSPACE_PATH_B]: 'git@github.com:myorg/repo-b.git',
|
|
114
|
+
},
|
|
115
|
+
userLogin: 'testuser',
|
|
116
|
+
searchItems: [
|
|
117
|
+
// Matches WORKSPACE_PATH_A
|
|
118
|
+
makeSearchItem({ ownerRepo: 'myorg/repo-a', number: 10, author: 'testuser' }),
|
|
119
|
+
// Matches WORKSPACE_PATH_B
|
|
120
|
+
makeSearchItem({ ownerRepo: 'myorg/repo-b', number: 20, author: 'testuser' }),
|
|
121
|
+
// Not in any workspace — should be excluded
|
|
122
|
+
makeSearchItem({ ownerRepo: 'myorg/other-repo', number: 30, author: 'testuser' }),
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
await startServer(exec);
|
|
126
|
+
const data = await getPrs();
|
|
127
|
+
assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
|
|
128
|
+
assert.equal(data.prs.length, 2, 'Should return only the 2 workspace-matched PRs');
|
|
129
|
+
const numbers = data.prs.map((p) => p.number).sort((a, b) => a - b);
|
|
130
|
+
assert.deepEqual(numbers, [10, 20]);
|
|
131
|
+
// Verify repoPath is attached
|
|
132
|
+
const pr10 = data.prs.find((p) => p.number === 10);
|
|
133
|
+
assert.equal(pr10?.repoPath, WORKSPACE_PATH_A);
|
|
134
|
+
});
|
|
135
|
+
test('returns gh_not_in_path error when gh not found', async () => {
|
|
136
|
+
await stopServer();
|
|
137
|
+
saveConfig(configPath, {
|
|
138
|
+
...DEFAULTS,
|
|
139
|
+
workspaces: [WORKSPACE_PATH_A],
|
|
140
|
+
});
|
|
141
|
+
const notFoundError = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' });
|
|
142
|
+
const exec = makeMockExec({
|
|
143
|
+
remotes: { [WORKSPACE_PATH_A]: 'git@github.com:myorg/repo-a.git' },
|
|
144
|
+
userError: notFoundError,
|
|
145
|
+
});
|
|
146
|
+
await startServer(exec);
|
|
147
|
+
const data = await getPrs();
|
|
148
|
+
assert.equal(data.error, 'gh_not_in_path');
|
|
149
|
+
assert.equal(data.prs.length, 0);
|
|
150
|
+
});
|
|
151
|
+
test('returns gh_not_authenticated error', async () => {
|
|
152
|
+
await stopServer();
|
|
153
|
+
saveConfig(configPath, {
|
|
154
|
+
...DEFAULTS,
|
|
155
|
+
workspaces: [WORKSPACE_PATH_A],
|
|
156
|
+
});
|
|
157
|
+
const authError = new Error('You are not logged into any GitHub hosts. Run gh auth login to authenticate.');
|
|
158
|
+
const exec = makeMockExec({
|
|
159
|
+
remotes: { [WORKSPACE_PATH_A]: 'git@github.com:myorg/repo-a.git' },
|
|
160
|
+
userError: authError,
|
|
161
|
+
});
|
|
162
|
+
await startServer(exec);
|
|
163
|
+
const data = await getPrs();
|
|
164
|
+
assert.equal(data.error, 'gh_not_authenticated');
|
|
165
|
+
assert.equal(data.prs.length, 0);
|
|
166
|
+
});
|
|
167
|
+
test('returns empty prs with no_workspaces error when workspaces is empty', async () => {
|
|
168
|
+
await stopServer();
|
|
169
|
+
saveConfig(configPath, {
|
|
170
|
+
...DEFAULTS,
|
|
171
|
+
workspaces: [],
|
|
172
|
+
});
|
|
173
|
+
// execAsync should never be called here — pass a mock that always throws to
|
|
174
|
+
// verify the early-return path triggers before any exec
|
|
175
|
+
const exec = makeMockExec({});
|
|
176
|
+
await startServer(exec);
|
|
177
|
+
const data = await getPrs();
|
|
178
|
+
assert.equal(data.error, 'no_workspaces');
|
|
179
|
+
assert.equal(data.prs.length, 0);
|
|
180
|
+
});
|
|
181
|
+
test('detects reviewer role when current user is in requested_reviewers but not the author', async () => {
|
|
182
|
+
await stopServer();
|
|
183
|
+
saveConfig(configPath, {
|
|
184
|
+
...DEFAULTS,
|
|
185
|
+
workspaces: [WORKSPACE_PATH_A],
|
|
186
|
+
});
|
|
187
|
+
// PR authored by someone else; testuser is a requested reviewer
|
|
188
|
+
const reviewerItem = makeSearchItem({
|
|
189
|
+
ownerRepo: 'myorg/repo-a',
|
|
190
|
+
number: 42,
|
|
191
|
+
title: 'Review me',
|
|
192
|
+
author: 'otheruser',
|
|
193
|
+
role: 'reviewer',
|
|
194
|
+
currentUser: 'testuser',
|
|
195
|
+
});
|
|
196
|
+
const exec = makeMockExec({
|
|
197
|
+
remotes: { [WORKSPACE_PATH_A]: 'git@github.com:myorg/repo-a.git' },
|
|
198
|
+
userLogin: 'testuser',
|
|
199
|
+
searchItems: [reviewerItem],
|
|
200
|
+
});
|
|
201
|
+
await startServer(exec);
|
|
202
|
+
const data = await getPrs();
|
|
203
|
+
assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
|
|
204
|
+
assert.equal(data.prs.length, 1, 'Should return the reviewer PR');
|
|
205
|
+
const pr = data.prs[0];
|
|
206
|
+
assert.equal(pr?.number, 42);
|
|
207
|
+
assert.equal(pr?.role, 'reviewer', 'Role should be reviewer');
|
|
208
|
+
assert.equal(pr?.author, 'otheruser', 'Author should be otheruser, not the current user');
|
|
209
|
+
});
|
|
210
|
+
test('caches results within TTL — exec called only once for two requests', async () => {
|
|
211
|
+
await stopServer();
|
|
212
|
+
saveConfig(configPath, {
|
|
213
|
+
...DEFAULTS,
|
|
214
|
+
workspaces: [WORKSPACE_PATH_A],
|
|
215
|
+
});
|
|
216
|
+
let searchCallCount = 0;
|
|
217
|
+
// Wrap makeMockExec with a counter on the search path
|
|
218
|
+
const baseExec = makeMockExec({
|
|
219
|
+
remotes: { [WORKSPACE_PATH_A]: 'git@github.com:myorg/repo-a.git' },
|
|
220
|
+
userLogin: 'testuser',
|
|
221
|
+
searchItems: [makeSearchItem({ ownerRepo: 'myorg/repo-a', number: 1, author: 'testuser' })],
|
|
222
|
+
});
|
|
223
|
+
const countingExec = async (...args) => {
|
|
224
|
+
const [cmd, argv] = args;
|
|
225
|
+
if (cmd === 'gh' && typeof argv[1] === 'string' && argv[1].startsWith('search/issues')) {
|
|
226
|
+
searchCallCount++;
|
|
227
|
+
}
|
|
228
|
+
return baseExec(...args);
|
|
229
|
+
};
|
|
230
|
+
await startServer(countingExec);
|
|
231
|
+
// First request — populates cache
|
|
232
|
+
const first = await getPrs();
|
|
233
|
+
assert.equal(first.error, undefined);
|
|
234
|
+
assert.equal(first.prs.length, 1);
|
|
235
|
+
// Second request — should be served from cache, no additional exec call
|
|
236
|
+
const second = await getPrs();
|
|
237
|
+
assert.equal(second.error, undefined);
|
|
238
|
+
assert.equal(second.prs.length, 1);
|
|
239
|
+
assert.equal(searchCallCount, 1, 'gh search should have been called exactly once (cache hit on second request)');
|
|
240
|
+
});
|