Skip to content

Commit 482b891

Browse files
committed
refactor: streamline CLI orchestration
Collapse the CLI pipeline onto buildReport so cli.ts resolves the target, gates scopes, builds the report and handles sharing without the old inline collector/aggregator wiring. Add top-level uncaughtException and unhandledRejection handlers in index.ts, and tidy the share/open prompt copy and factories.
1 parent b22d737 commit 482b891

8 files changed

Lines changed: 200 additions & 517 deletions

File tree

src/cli.test.ts

Lines changed: 26 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,12 @@ import { main } from './cli.ts';
33
import { createFakeContext } from './testHelpers/index.ts';
44
import type { FakeGithubClient } from './testHelpers/index.ts';
55

6-
function repoBatchResponse(nodes: unknown[] = []): Record<string, unknown> {
7-
return { nodes };
8-
}
9-
10-
const npmConfigBlob = {
11-
__typename: 'Repository',
12-
yml: { text: 'updates:\n - package-ecosystem: "npm"\n' },
13-
yaml: null,
14-
defaultBranchRef: null,
15-
};
16-
17-
// The pre-flight probes one repo's pulls endpoint before scanning; stub it as
18-
// readable so the happy-path tests proceed to the scan.
19-
function stubPrReadable(githubClient: FakeGithubClient): void {
20-
githubClient.onRequest('GET /repos/{owner}/{repo}/pulls').resolves([]);
6+
// The Dependabot PR search is the only GraphQL call; resolve it empty so the
7+
// happy-path tests reach the report without exercising the search internals.
8+
function stubEmptyPrSearch(githubClient: FakeGithubClient): void {
9+
githubClient.onGraphql('DependabotPrsSearch').resolves({
10+
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
11+
});
2112
}
2213

2314
test('prints usage and exits 0 when --help is passed', async () => {
@@ -31,7 +22,6 @@ test('prompts for the target when no positional is provided, picking from the li
3122
const { ctx, prompter, githubClient } = createFakeContext();
3223
githubClient.onRequest('GET /user').resolves({ login: 'ben' });
3324
githubClient.onPaginate('GET /user/orgs').resolves([{ login: 'acme' }]);
34-
githubClient.onRequest('GET /user/repos').resolves([]);
3525
prompter.scriptSelect('acme');
3626
// The chosen org then drives the real run, which fails the listing — we
3727
// only care that the select fired and that target_prompted is recorded.
@@ -47,7 +37,6 @@ test('cancelling the target prompt returns failed and tells the user why', async
4737
const { ctx, prompter, githubClient } = createFakeContext();
4838
githubClient.onRequest('GET /user').resolves({ login: 'ben' });
4939
githubClient.onPaginate('GET /user/orgs').resolves([]);
50-
githubClient.onRequest('GET /user/repos').resolves([]);
5140
prompter.scriptSelect({ kind: 'cancelled' });
5241

5342
const result = await main(ctx, []);
@@ -86,10 +75,8 @@ test('writes a report when the GitHub calls succeed', async () => {
8675
pushed_at: '2026-04-01T00:00:00Z',
8776
},
8877
]);
89-
stubPrReadable(githubClient);
90-
githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob]));
9178
githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]);
92-
githubClient.onGraphql('DependabotPrs').resolves({ nodes: [] });
79+
stubEmptyPrSearch(githubClient);
9380

9481
const result = await main(ctx, ['acme']);
9582
expect(result.kind).toBe('completed');
@@ -98,9 +85,9 @@ test('writes a report when the GitHub calls succeed', async () => {
9885

9986
// Output lands in a temp dir, not the CWD; locate it via the returned paths.
10087
expect(result.run.target).toBe('acme');
101-
expect(result.run.paths.html.endsWith('patchwave-report.html')).toBe(true);
88+
expect(result.run.htmlPath.endsWith('patchwave-report.html')).toBe(true);
10289

103-
const written = fs.read(result.run.paths.html);
90+
const written = fs.read(result.run.htmlPath);
10491
expect(written).toBeDefined();
10592
expect(written).toContain('<html');
10693
const match = /<script type="application\/json" id="patchwave-data">([\s\S]*?)<\/script>/.exec(written ?? '');
@@ -115,8 +102,6 @@ test('writes a report when the GitHub calls succeed', async () => {
115102

116103
expect(analytics.capturedEvents('run_started')[0]?.properties).toMatchObject({
117104
window_days: 90,
118-
has_include: false,
119-
has_exclude: false,
120105
target_prompted: false,
121106
});
122107
const completed = analytics.capturedEvents('run_completed')[0];
@@ -161,10 +146,8 @@ test('excludes forked repos from the crawl', async () => {
161146
pushed_at: '2026-04-01T00:00:00Z',
162147
},
163148
]);
164-
stubPrReadable(githubClient);
165-
githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob]));
166149
githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]);
167-
githubClient.onGraphql('DependabotPrs').resolves({ nodes: [] });
150+
stubEmptyPrSearch(githubClient);
168151

169152
const result = await main(ctx, ['acme']);
170153
expect(result.kind).toBe('completed');
@@ -194,11 +177,9 @@ test('uses the per-repo CVE endpoint for user targets', async () => {
194177
pushed_at: '2026-04-01T00:00:00Z',
195178
},
196179
]);
197-
stubPrReadable(githubClient);
198-
githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob]));
199-
// Per-repo CVE endpoint — the path Task 4 keeps for user targets.
180+
// Per-repo CVE endpoint — the path kept for user targets.
200181
githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]);
201-
githubClient.onGraphql('DependabotPrs').resolves({ nodes: [] });
182+
stubEmptyPrSearch(githubClient);
202183

203184
const result = await main(ctx, ['blimmer']);
204185
expect(result.kind).toBe('completed');
@@ -224,15 +205,13 @@ test('falls back to the per-repo CVE endpoint when the org-level call fails', as
224205
pushed_at: '2026-04-01T00:00:00Z',
225206
},
226207
]);
227-
stubPrReadable(githubClient);
228-
githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob]));
229208
// Org-level endpoint refuses: token can see the org but not its alerts.
230209
githubClient
231210
.onPaginate('GET /orgs/{org}/dependabot/alerts', {})
232211
.fails({ kind: 'forbidden', message: 'no access to org alerts' });
233212
// Per-repo endpoint is the fallback so each repo still gets a real status.
234213
githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]);
235-
githubClient.onGraphql('DependabotPrs').resolves({ nodes: [] });
214+
stubEmptyPrSearch(githubClient);
236215

237216
const result = await main(ctx, ['acme']);
238217
expect(result.kind).toBe('completed');
@@ -242,65 +221,30 @@ test('falls back to the per-repo CVE endpoint when the org-level call fails', as
242221
expect(paginateRoutes).toContain('GET /repos/{owner}/{repo}/dependabot/alerts');
243222
});
244223

245-
test('aborts before scanning when the token cannot read PRs and the user chooses to stop', async () => {
224+
test('aborts before scanning when the token is missing a required scope', async () => {
246225
const { ctx, githubClient, prompter, analytics } = createFakeContext();
247-
248-
githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([
249-
{
250-
name: 'widgets',
251-
node_id: 'R_kgDOwidgets',
252-
owner: { login: 'acme' },
253-
private: true,
254-
visibility: 'private',
255-
archived: false,
256-
default_branch: 'main',
257-
language: 'TypeScript',
258-
pushed_at: '2026-04-01T00:00:00Z',
259-
},
260-
]);
261-
githubClient.onRequest('GET /repos/{owner}/{repo}/pulls').fails({ kind: 'forbidden', message: 'no pulls' });
262-
prompter.scriptSelect('stop');
226+
githubClient.setOAuthScopes(['read:org']); // missing `repo`
263227

264228
const result = await main(ctx, ['acme']);
265229

266230
expect(result).toMatchObject({ kind: 'failed', code: 1 });
267-
// No scan was attempted: the Dependabot PR search never ran.
231+
// The gate fires before any repo listing or scan.
232+
expect(githubClient.callsTo('paginate')).toHaveLength(0);
268233
expect(githubClient.callsTo('graphql')).toHaveLength(0);
269-
expect(prompter.notes.some((n) => n.title === 'Grant pull-request access')).toBe(true);
270-
expect(analytics.capturedEvents('run_failed')[0]?.properties).toMatchObject({ error_kind: 'pr-access-declined' });
234+
expect(prompter.notes.some((n) => n.title === 'Fix the token')).toBe(true);
235+
expect(analytics.capturedEvents('run_failed')[0]?.properties).toMatchObject({
236+
error_kind: 'token-scope-insufficient',
237+
});
271238
});
272239

273-
test('continues with an incomplete report when the token cannot read PRs but the user proceeds', async () => {
274-
const { ctx, githubClient, prompter, analytics } = createFakeContext();
275-
276-
githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([
277-
{
278-
name: 'widgets',
279-
node_id: 'R_kgDOwidgets',
280-
owner: { login: 'acme' },
281-
private: true,
282-
visibility: 'private',
283-
archived: false,
284-
default_branch: 'main',
285-
language: 'TypeScript',
286-
pushed_at: '2026-04-01T00:00:00Z',
287-
},
288-
]);
289-
githubClient.onRequest('GET /repos/{owner}/{repo}/pulls').fails({ kind: 'forbidden', message: 'no pulls' });
290-
githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob]));
291-
githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]);
292-
githubClient.onGraphql('DependabotPrs').resolves({ nodes: [] });
293-
prompter.scriptSelect('continue');
240+
test('rejects a fine-grained token, which carries no scopes header', async () => {
241+
const { ctx, githubClient, prompter } = createFakeContext();
242+
githubClient.setOAuthScopes(null);
294243

295244
const result = await main(ctx, ['acme']);
296-
expect(result.kind).toBe('completed');
297245

298-
// The report is written, but the CLI warns that it's incomplete — the report
299-
// itself carries no permission banner.
300-
expect(prompter.notes.some((n) => n.title === 'Heads up')).toBe(true);
301-
expect(analytics.capturedEvents('run_completed')[0]?.properties).toMatchObject({
302-
pr_access: 'unreadable',
303-
});
246+
expect(result).toMatchObject({ kind: 'failed', code: 1 });
247+
expect(prompter.notes.some((n) => n.message.includes('classic token'))).toBe(true);
304248
});
305249

306250
test('captures run_failed when listTargetRepos fails', async () => {

0 commit comments

Comments
 (0)