Skip to content

Commit 60bfbcc

Browse files
authored
Merge pull request #388 from yowainwright/update-retry
fix: fixes retry
2 parents ed9d751 + 3d9a8c6 commit 60bfbcc

File tree

13 files changed

+847
-831
lines changed

13 files changed

+847
-831
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "pastoralist",

src/cli/index.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,39 @@ export const runSecurityCheck = async (
109109
)
110110
.start();
111111

112-
const securityChecker = new deps.SecurityChecker({
113-
provider: mergedOptions.securityProvider,
114-
forceRefactor: mergedOptions.forceSecurityRefactor,
115-
interactive: mergedOptions.interactive,
116-
token: mergedOptions.securityProviderToken,
117-
debug: isLogging,
118-
isIRLFix: mergedOptions.isIRLFix,
119-
isIRLCatch: mergedOptions.isIRLCatch,
120-
});
121-
122-
const scanPaths = deps.determineSecurityScanPaths(config, mergedOptions, log);
123-
const {
124-
alerts,
125-
overrides: securityOverrides,
126-
updates,
127-
} = await securityChecker.checkSecurity(config, {
128-
...mergedOptions,
129-
depPaths: scanPaths,
130-
root: mergedOptions.root || "./",
131-
});
132-
133-
return { spinner, securityChecker, alerts, securityOverrides, updates };
112+
try {
113+
const securityChecker = new deps.SecurityChecker({
114+
provider: mergedOptions.securityProvider,
115+
forceRefactor: mergedOptions.forceSecurityRefactor,
116+
interactive: mergedOptions.interactive,
117+
token: mergedOptions.securityProviderToken,
118+
debug: isLogging,
119+
isIRLFix: mergedOptions.isIRLFix,
120+
isIRLCatch: mergedOptions.isIRLCatch,
121+
});
122+
123+
const scanPaths = deps.determineSecurityScanPaths(
124+
config,
125+
mergedOptions,
126+
log,
127+
);
128+
const {
129+
alerts,
130+
overrides: securityOverrides,
131+
updates,
132+
} = await securityChecker.checkSecurity(config, {
133+
...mergedOptions,
134+
depPaths: scanPaths,
135+
root: mergedOptions.root || "./",
136+
});
137+
138+
return { spinner, securityChecker, alerts, securityOverrides, updates };
139+
} catch (error) {
140+
spinner.fail(
141+
`🔒 ${deps.green(`pastoralist`)} security check failed: ${error instanceof Error ? error.message : String(error)}`,
142+
);
143+
throw error;
144+
}
134145
};
135146

136147
export const handleSecurityResults = (

src/core/security/providers/github.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515

1616
const execFileAsync = promisify(execFile);
1717

18+
const DEFAULT_FETCH_TIMEOUT = 30000;
19+
const DEFAULT_CLI_TIMEOUT = 60000;
20+
1821
export class GitHubSecurityProvider {
1922
private owner: string;
2023
private repo: string;
@@ -208,7 +211,9 @@ export class GitHubSecurityProvider {
208211
"executeGhCli",
209212
);
210213

211-
const { stdout } = await execFileAsync("gh", args);
214+
const { stdout } = await execFileAsync("gh", args, {
215+
timeout: DEFAULT_CLI_TIMEOUT,
216+
});
212217
this.log.debug(`gh CLI stdout length: ${stdout.length}`, "executeGhCli");
213218
return stdout;
214219
}
@@ -246,23 +251,33 @@ export class GitHubSecurityProvider {
246251

247252
private async fetchFromGitHubAPI(): Promise<DependabotAlert[]> {
248253
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/dependabot/alerts`;
254+
const controller = new AbortController();
255+
const timeoutId = setTimeout(
256+
() => controller.abort(),
257+
DEFAULT_FETCH_TIMEOUT,
258+
);
249259

250-
const response = await fetch(url, {
251-
headers: {
252-
Authorization: `Bearer ${this.token}`,
253-
Accept: "application/vnd.github.v3+json",
254-
},
255-
});
260+
try {
261+
const response = await fetch(url, {
262+
headers: {
263+
Authorization: `Bearer ${this.token}`,
264+
Accept: "application/vnd.github.v3+json",
265+
},
266+
signal: controller.signal,
267+
});
256268

257-
if (!response.ok) {
258-
const error: GithubApiError = await response.json();
259-
throw new Error(
260-
`GitHub API error: ${error.message || response.statusText}`,
261-
);
262-
}
269+
if (!response.ok) {
270+
const error: GithubApiError = await response.json();
271+
throw new Error(
272+
`GitHub API error: ${error.message || response.statusText}`,
273+
);
274+
}
263275

264-
const alerts = await response.json();
265-
return Array.isArray(alerts) ? alerts : [];
276+
const alerts = await response.json();
277+
return Array.isArray(alerts) ? alerts : [];
278+
} finally {
279+
clearTimeout(timeoutId);
280+
}
266281
}
267282

268283
private async fetchAlertsWithApi(): Promise<DependabotAlert[]> {

src/core/update/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ export const hasOverrides = (
131131

132132
return Boolean(
133133
(optionsOverrides && Object.keys(optionsOverrides).length > 0) ||
134-
(configOverrides && Object.keys(configOverrides).length > 0) ||
135-
(configResolutions && Object.keys(configResolutions).length > 0) ||
136-
(configPnpmOverrides && Object.keys(configPnpmOverrides).length > 0),
134+
(configOverrides && Object.keys(configOverrides).length > 0) ||
135+
(configResolutions && Object.keys(configResolutions).length > 0) ||
136+
(configPnpmOverrides && Object.keys(configPnpmOverrides).length > 0),
137137
);
138138
};

src/utils/retry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { RetryOptions, RetryError } from "./types";
22

3-
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, "onFailedAttempt">> = {
3+
const DEFAULT_OPTIONS: Required<
4+
Omit<RetryOptions, "onFailedAttempt" | "onRetry">
5+
> = {
46
retries: 3,
57
factor: 2,
68
minTimeout: 1000,
@@ -42,6 +44,7 @@ export const retry = async <T>(
4244
minTimeout = DEFAULT_OPTIONS.minTimeout,
4345
maxTimeout = DEFAULT_OPTIONS.maxTimeout,
4446
onFailedAttempt,
47+
onRetry,
4548
} = options;
4649

4750
let attemptNumber = 0;
@@ -69,6 +72,10 @@ export const retry = async <T>(
6972
await onFailedAttempt(retryError);
7073
}
7174

75+
if (onRetry) {
76+
onRetry(attemptNumber, clampedRetriesLeft);
77+
}
78+
7279
const delay = calculateDelay(
7380
attemptNumber,
7481
factor,

src/utils/spinner.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export const warn = (state: SpinnerState, text?: string): Spinner => {
120120
return createSpinnerMethods(state);
121121
};
122122

123+
export const update = (state: SpinnerState, text: string): Spinner => {
124+
const newState = updateStateText(state, text);
125+
Object.assign(state, newState);
126+
return createSpinnerMethods(state);
127+
};
128+
123129
export const createSpinnerMethods = (state: SpinnerState): Spinner => {
124130
return {
125131
start: () => start(state),
@@ -128,6 +134,7 @@ export const createSpinnerMethods = (state: SpinnerState): Spinner => {
128134
fail: (text?: string) => fail(state, text),
129135
info: (text?: string) => info(state, text),
130136
warn: (text?: string) => warn(state, text),
137+
update: (text: string) => update(state, text),
131138
};
132139
};
133140

src/utils/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type Spinner = {
1212
fail: (text?: string) => Spinner;
1313
info: (text?: string) => Spinner;
1414
warn: (text?: string) => Spinner;
15+
update: (text: string) => Spinner;
1516
};
1617

1718
export type RGB = {
@@ -49,6 +50,7 @@ export interface RetryOptions {
4950
minTimeout?: number;
5051
maxTimeout?: number;
5152
onFailedAttempt?: (error: RetryError) => void | Promise<void>;
53+
onRetry?: (attemptNumber: number, retriesLeft: number) => void;
5254
}
5355

5456
export interface RetryError extends Error {

tests/unit/cli/index.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,3 +1860,83 @@ test("determineSecurityScanPaths - handles workspace with hasWorkspaceSecurityCh
18601860

18611861
expect(result).toEqual([]);
18621862
});
1863+
1864+
test("runSecurityCheck - handles error and calls spinner.fail", async () => {
1865+
const { runSecurityCheck } = require("../../../src/cli/index");
1866+
1867+
const config: PastoralistJSON = {
1868+
name: "test",
1869+
version: "1.0.0",
1870+
};
1871+
1872+
const mergedOptions: Options = {
1873+
checkSecurity: true,
1874+
securityProvider: "osv",
1875+
};
1876+
1877+
const mockSpinner = {
1878+
start: mock(() => mockSpinner),
1879+
fail: mock(),
1880+
};
1881+
1882+
const testError = new Error("Security check failed");
1883+
1884+
const mockSecurityChecker = {
1885+
checkSecurity: mock(() => Promise.reject(testError)),
1886+
};
1887+
1888+
const deps = {
1889+
createSpinner: mock(() => mockSpinner),
1890+
SecurityChecker: mock(() => mockSecurityChecker),
1891+
determineSecurityScanPaths: mock(() => []),
1892+
green: mock((text: string) => text),
1893+
};
1894+
1895+
await expect(
1896+
runSecurityCheck(config, mergedOptions, false, log, deps),
1897+
).rejects.toThrow("Security check failed");
1898+
1899+
expect(mockSpinner.fail).toHaveBeenCalled();
1900+
const failCall = mockSpinner.fail.mock.calls[0][0];
1901+
expect(failCall).toContain("security check failed");
1902+
expect(failCall).toContain("Security check failed");
1903+
});
1904+
1905+
test("runSecurityCheck - handles non-Error throws and calls spinner.fail", async () => {
1906+
const { runSecurityCheck } = require("../../../src/cli/index");
1907+
1908+
const config: PastoralistJSON = {
1909+
name: "test",
1910+
version: "1.0.0",
1911+
};
1912+
1913+
const mergedOptions: Options = {
1914+
checkSecurity: true,
1915+
securityProvider: "osv",
1916+
};
1917+
1918+
const mockSpinner = {
1919+
start: mock(() => mockSpinner),
1920+
fail: mock(),
1921+
};
1922+
1923+
const mockSecurityChecker = {
1924+
checkSecurity: mock(() => Promise.reject("String error")),
1925+
};
1926+
1927+
const deps = {
1928+
createSpinner: mock(() => mockSpinner),
1929+
SecurityChecker: mock(() => mockSecurityChecker),
1930+
determineSecurityScanPaths: mock(() => []),
1931+
green: mock((text: string) => text),
1932+
};
1933+
1934+
await expect(
1935+
runSecurityCheck(config, mergedOptions, false, log, deps),
1936+
).rejects.toBe("String error");
1937+
1938+
expect(mockSpinner.fail).toHaveBeenCalled();
1939+
const failCall = mockSpinner.fail.mock.calls[0][0];
1940+
expect(failCall).toContain("security check failed");
1941+
expect(failCall).toContain("String error");
1942+
});

0 commit comments

Comments
 (0)