claude-remote-cli 3.9.4 → 3.10.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-BTOnhJQN.css +32 -0
- package/dist/frontend/assets/index-Dgf6cKGu.js +52 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/branch-linker.js +136 -0
- package/dist/server/config.js +31 -1
- package/dist/server/index.js +260 -6
- package/dist/server/integration-github.js +117 -0
- package/dist/server/integration-jira.js +177 -0
- package/dist/server/integration-linear.js +176 -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 +265 -0
- package/dist/server/watcher.js +124 -0
- package/dist/test/branch-linker.test.js +231 -0
- package/dist/test/branch-watcher.test.js +73 -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 +302 -0
- package/dist/test/integration-linear.test.js +293 -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 +470 -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,293 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { createIntegrationLinearRouter } from '../server/integration-linear.js';
|
|
5
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
6
|
+
let server;
|
|
7
|
+
let baseUrl;
|
|
8
|
+
// Saved before any mock replaces globalThis.fetch so test HTTP calls
|
|
9
|
+
// to the local Express server always reach it even when fetch is mocked.
|
|
10
|
+
let httpFetch;
|
|
11
|
+
let originalApiKey;
|
|
12
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
13
|
+
/** Builds a single Linear issue node as returned by the GraphQL API. */
|
|
14
|
+
function makeIssueNode(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
id: overrides.id ?? 'issue-1',
|
|
17
|
+
identifier: overrides.identifier ?? 'ENG-1',
|
|
18
|
+
title: overrides.title ?? 'Test Issue',
|
|
19
|
+
url: overrides.url ?? 'https://linear.app/team/issue/ENG-1',
|
|
20
|
+
state: overrides.stateName != null ? { name: overrides.stateName } : { name: 'In Progress' },
|
|
21
|
+
priority: overrides.priority ?? 2,
|
|
22
|
+
priorityLabel: overrides.priorityLabel ?? 'Medium',
|
|
23
|
+
cycle: (overrides.cycle !== undefined)
|
|
24
|
+
? (overrides.cycle !== null ? { name: overrides.cycle } : null)
|
|
25
|
+
: null,
|
|
26
|
+
estimate: overrides.estimate ?? null,
|
|
27
|
+
assignee: overrides.assigneeName != null ? { name: overrides.assigneeName } : null,
|
|
28
|
+
updatedAt: overrides.updatedAt ?? '2026-03-21T00:00:00Z',
|
|
29
|
+
team: overrides.teamId != null ? { id: overrides.teamId } : { id: 'team-abc' },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Builds a GraphQL issues response envelope. */
|
|
33
|
+
function makeIssuesGqlResponse(nodes) {
|
|
34
|
+
return {
|
|
35
|
+
data: {
|
|
36
|
+
viewer: {
|
|
37
|
+
assignedIssues: {
|
|
38
|
+
nodes,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Builds a GraphQL workflow states response envelope. */
|
|
45
|
+
function makeStatesGqlResponse(nodes) {
|
|
46
|
+
return {
|
|
47
|
+
data: {
|
|
48
|
+
workflowStates: {
|
|
49
|
+
nodes,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns a function suitable for replacing globalThis.fetch.
|
|
56
|
+
* The returned mock always resolves with a minimal Response-shaped object.
|
|
57
|
+
*/
|
|
58
|
+
function makeMockFetch(gqlBody, opts = {}) {
|
|
59
|
+
const status = opts.status ?? 200;
|
|
60
|
+
const ok = opts.ok !== undefined ? opts.ok : (status >= 200 && status < 300);
|
|
61
|
+
return (async () => ({
|
|
62
|
+
ok,
|
|
63
|
+
status,
|
|
64
|
+
json: async () => gqlBody,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
function startServer() {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const app = express();
|
|
70
|
+
app.use(express.json());
|
|
71
|
+
// configPath is unused by the Linear router at runtime; pass a dummy.
|
|
72
|
+
app.use('/integration-linear', createIntegrationLinearRouter({ configPath: '/dev/null' }));
|
|
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
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
91
|
+
// Each test gets a fresh server instance so the module-level issuesCache starts
|
|
92
|
+
// at null. This is the only reliable way to clear cache state without exposing
|
|
93
|
+
// internals, since issuesCache lives in the router closure.
|
|
94
|
+
beforeEach(async () => {
|
|
95
|
+
await startServer();
|
|
96
|
+
// Capture real fetch AFTER server starts (so baseUrl is set) but BEFORE any
|
|
97
|
+
// test mock replaces it. Used for all test-to-server HTTP calls.
|
|
98
|
+
httpFetch = globalThis.fetch;
|
|
99
|
+
originalApiKey = process.env['LINEAR_API_KEY'];
|
|
100
|
+
});
|
|
101
|
+
afterEach(async () => {
|
|
102
|
+
await stopServer();
|
|
103
|
+
globalThis.fetch = httpFetch;
|
|
104
|
+
if (originalApiKey === undefined) {
|
|
105
|
+
delete process.env['LINEAR_API_KEY'];
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
process.env['LINEAR_API_KEY'] = originalApiKey;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// ─── GET /configured ─────────────────────────────────────────────────────────
|
|
112
|
+
test('GET /configured — returns { configured: true } when LINEAR_API_KEY is set', async () => {
|
|
113
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
114
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/configured`);
|
|
115
|
+
assert.equal(res.ok, true);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
assert.deepEqual(body, { configured: true });
|
|
118
|
+
});
|
|
119
|
+
test('GET /configured — returns { configured: false } when LINEAR_API_KEY is not set', async () => {
|
|
120
|
+
delete process.env['LINEAR_API_KEY'];
|
|
121
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/configured`);
|
|
122
|
+
assert.equal(res.ok, true);
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
assert.deepEqual(body, { configured: false });
|
|
125
|
+
});
|
|
126
|
+
// ─── GET /issues ──────────────────────────────────────────────────────────────
|
|
127
|
+
test('GET /issues — returns linear_not_configured error when API key is missing', async () => {
|
|
128
|
+
delete process.env['LINEAR_API_KEY'];
|
|
129
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
130
|
+
assert.equal(res.ok, true);
|
|
131
|
+
const body = await res.json();
|
|
132
|
+
assert.equal(body.error, 'linear_not_configured');
|
|
133
|
+
assert.deepEqual(body.issues, []);
|
|
134
|
+
});
|
|
135
|
+
test('GET /issues — returns mapped LinearIssue[] from mocked GraphQL response', async () => {
|
|
136
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
137
|
+
const nodes = [
|
|
138
|
+
makeIssueNode({
|
|
139
|
+
id: 'issue-abc',
|
|
140
|
+
identifier: 'ENG-42',
|
|
141
|
+
title: 'Build something',
|
|
142
|
+
url: 'https://linear.app/team/issue/ENG-42',
|
|
143
|
+
stateName: 'In Progress',
|
|
144
|
+
priority: 1,
|
|
145
|
+
priorityLabel: 'Urgent',
|
|
146
|
+
cycle: 'Sprint 5',
|
|
147
|
+
estimate: 3,
|
|
148
|
+
assigneeName: 'Alice',
|
|
149
|
+
updatedAt: '2026-03-21T12:00:00Z',
|
|
150
|
+
teamId: 'team-xyz',
|
|
151
|
+
}),
|
|
152
|
+
];
|
|
153
|
+
globalThis.fetch = makeMockFetch(makeIssuesGqlResponse(nodes));
|
|
154
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
155
|
+
assert.equal(res.ok, true);
|
|
156
|
+
const body = await res.json();
|
|
157
|
+
assert.equal(body.error, undefined, `Unexpected error: ${body.error}`);
|
|
158
|
+
assert.equal(body.issues.length, 1);
|
|
159
|
+
const issue = body.issues[0];
|
|
160
|
+
assert.equal(issue.id, 'issue-abc');
|
|
161
|
+
assert.equal(issue.identifier, 'ENG-42');
|
|
162
|
+
assert.equal(issue.title, 'Build something');
|
|
163
|
+
assert.equal(issue.url, 'https://linear.app/team/issue/ENG-42');
|
|
164
|
+
assert.equal(issue.state, 'In Progress');
|
|
165
|
+
assert.equal(issue.priority, 1);
|
|
166
|
+
assert.equal(issue.priorityLabel, 'Urgent');
|
|
167
|
+
assert.equal(issue.cycle, 'Sprint 5');
|
|
168
|
+
assert.equal(issue.estimate, 3);
|
|
169
|
+
assert.equal(issue.assignee, 'Alice');
|
|
170
|
+
assert.equal(issue.updatedAt, '2026-03-21T12:00:00Z');
|
|
171
|
+
assert.equal(issue.teamId, 'team-xyz');
|
|
172
|
+
});
|
|
173
|
+
test('GET /issues — caches results within TTL (fetch called only once for two requests)', async () => {
|
|
174
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
175
|
+
let fetchCallCount = 0;
|
|
176
|
+
const nodes = [makeIssueNode({ id: 'cached-issue', identifier: 'ENG-99' })];
|
|
177
|
+
const mockBody = makeIssuesGqlResponse(nodes);
|
|
178
|
+
globalThis.fetch = (async () => {
|
|
179
|
+
fetchCallCount++;
|
|
180
|
+
return { ok: true, status: 200, json: async () => mockBody };
|
|
181
|
+
});
|
|
182
|
+
// First request — populates cache
|
|
183
|
+
const first = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
184
|
+
const firstBody = await first.json();
|
|
185
|
+
assert.equal(firstBody.error, undefined, `Unexpected error on first request: ${firstBody.error}`);
|
|
186
|
+
assert.equal(firstBody.issues.length, 1);
|
|
187
|
+
assert.equal(fetchCallCount, 1, 'fetch should be called once on the first request');
|
|
188
|
+
// Second request — should be served from cache, no additional fetch calls
|
|
189
|
+
const second = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
190
|
+
const secondBody = await second.json();
|
|
191
|
+
assert.equal(secondBody.error, undefined, `Unexpected error on second request: ${secondBody.error}`);
|
|
192
|
+
assert.equal(secondBody.issues.length, 1);
|
|
193
|
+
assert.equal(fetchCallCount, 1, 'fetch should not be called again within TTL (cache hit)');
|
|
194
|
+
});
|
|
195
|
+
// ─── GET /states ──────────────────────────────────────────────────────────────
|
|
196
|
+
test('GET /states — returns workflow states from mocked GraphQL response', async () => {
|
|
197
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
198
|
+
const stateNodes = [
|
|
199
|
+
{ id: 'state-1', name: 'Backlog' },
|
|
200
|
+
{ id: 'state-2', name: 'In Progress' },
|
|
201
|
+
{ id: 'state-3', name: 'Done' },
|
|
202
|
+
];
|
|
203
|
+
globalThis.fetch = makeMockFetch(makeStatesGqlResponse(stateNodes));
|
|
204
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
|
|
205
|
+
assert.equal(res.ok, true);
|
|
206
|
+
const body = await res.json();
|
|
207
|
+
assert.equal(body.error, undefined, `Unexpected error: ${body.error}`);
|
|
208
|
+
assert.equal(body.states.length, 3);
|
|
209
|
+
assert.deepEqual(body.states, stateNodes);
|
|
210
|
+
});
|
|
211
|
+
test('GET /states — returns linear_not_configured error when API key is missing', async () => {
|
|
212
|
+
delete process.env['LINEAR_API_KEY'];
|
|
213
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
|
|
214
|
+
assert.equal(res.ok, true);
|
|
215
|
+
const body = await res.json();
|
|
216
|
+
assert.equal(body.error, 'linear_not_configured');
|
|
217
|
+
assert.deepEqual(body.states, []);
|
|
218
|
+
});
|
|
219
|
+
test('GET /states — returns 400 missing_team_id when teamId query param is absent', async () => {
|
|
220
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
221
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/states`);
|
|
222
|
+
assert.equal(res.status, 400);
|
|
223
|
+
const body = await res.json();
|
|
224
|
+
assert.equal(body.error, 'missing_team_id');
|
|
225
|
+
});
|
|
226
|
+
// ─── Error handling ───────────────────────────────────────────────────────────
|
|
227
|
+
test('auth failure (HTTP 401) returns linear_auth_failed for /issues', async () => {
|
|
228
|
+
process.env['LINEAR_API_KEY'] = 'lin_bad_key';
|
|
229
|
+
globalThis.fetch = makeMockFetch({}, { status: 401, ok: false });
|
|
230
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
231
|
+
assert.equal(res.ok, true);
|
|
232
|
+
const body = await res.json();
|
|
233
|
+
assert.equal(body.error, 'linear_auth_failed');
|
|
234
|
+
assert.deepEqual(body.issues, []);
|
|
235
|
+
});
|
|
236
|
+
test('auth failure (HTTP 403) returns linear_auth_failed for /issues', async () => {
|
|
237
|
+
process.env['LINEAR_API_KEY'] = 'lin_bad_key';
|
|
238
|
+
globalThis.fetch = makeMockFetch({}, { status: 403, ok: false });
|
|
239
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
240
|
+
assert.equal(res.ok, true);
|
|
241
|
+
const body = await res.json();
|
|
242
|
+
assert.equal(body.error, 'linear_auth_failed');
|
|
243
|
+
assert.deepEqual(body.issues, []);
|
|
244
|
+
});
|
|
245
|
+
test('non-ok response (HTTP 500) returns linear_fetch_failed for /issues', async () => {
|
|
246
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
247
|
+
globalThis.fetch = makeMockFetch({}, { status: 500, ok: false });
|
|
248
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
249
|
+
assert.equal(res.ok, true);
|
|
250
|
+
const body = await res.json();
|
|
251
|
+
assert.equal(body.error, 'linear_fetch_failed');
|
|
252
|
+
assert.deepEqual(body.issues, []);
|
|
253
|
+
});
|
|
254
|
+
test('GraphQL-level authentication error returns linear_auth_failed', async () => {
|
|
255
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
256
|
+
const gqlAuthError = {
|
|
257
|
+
errors: [{ extensions: { type: 'authentication' } }],
|
|
258
|
+
data: null,
|
|
259
|
+
};
|
|
260
|
+
globalThis.fetch = makeMockFetch(gqlAuthError);
|
|
261
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
262
|
+
assert.equal(res.ok, true);
|
|
263
|
+
const body = await res.json();
|
|
264
|
+
assert.equal(body.error, 'linear_auth_failed');
|
|
265
|
+
assert.deepEqual(body.issues, []);
|
|
266
|
+
});
|
|
267
|
+
test('network error (fetch throws) returns linear_fetch_failed for /issues', async () => {
|
|
268
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
269
|
+
globalThis.fetch = (async () => { throw new Error('Network failure'); });
|
|
270
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
|
|
271
|
+
assert.equal(res.ok, true);
|
|
272
|
+
const body = await res.json();
|
|
273
|
+
assert.equal(body.error, 'linear_fetch_failed');
|
|
274
|
+
assert.deepEqual(body.issues, []);
|
|
275
|
+
});
|
|
276
|
+
test('auth failure (HTTP 401) returns linear_auth_failed for /states', async () => {
|
|
277
|
+
process.env['LINEAR_API_KEY'] = 'lin_bad_key';
|
|
278
|
+
globalThis.fetch = makeMockFetch({}, { status: 401, ok: false });
|
|
279
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
|
|
280
|
+
assert.equal(res.ok, true);
|
|
281
|
+
const body = await res.json();
|
|
282
|
+
assert.equal(body.error, 'linear_auth_failed');
|
|
283
|
+
assert.deepEqual(body.states, []);
|
|
284
|
+
});
|
|
285
|
+
test('non-ok response (HTTP 500) returns linear_fetch_failed for /states', async () => {
|
|
286
|
+
process.env['LINEAR_API_KEY'] = 'lin_test_key';
|
|
287
|
+
globalThis.fetch = makeMockFetch({}, { status: 500, ok: false });
|
|
288
|
+
const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
|
|
289
|
+
assert.equal(res.ok, true);
|
|
290
|
+
const body = await res.json();
|
|
291
|
+
assert.equal(body.error, 'linear_fetch_failed');
|
|
292
|
+
assert.deepEqual(body.states, []);
|
|
293
|
+
});
|
|
@@ -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
|
+
});
|