Skip to content

Commit 9e2cbb2

Browse files
committed
fix: downloads proceeding without clear error for non-existent repositories
Previously, 404 errors from GitHub API were silently caught and default branch "main" was returned, causing downloads to proceed with confusing downstream errors Improved to fail early with clear error messages for each HTTP status code - 404: guide to verify repository existence and access - 401/403: suggest token provision or permission check - Network errors: wrapped in NetworkError for consistent handling fix #29
1 parent e1fe9ec commit 9e2cbb2

2 files changed

Lines changed: 248 additions & 6 deletions

File tree

src/pkg/pull/github.spec.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { GitHubAPIError, NetworkError } from "../../internal/core/errors/";
2+
3+
describe("getGitHubDefaultBranch", () => {
4+
describe("GitHubAPIError - 404 Not Found", () => {
5+
it("should create error with repository not found message", () => {
6+
const error = new GitHubAPIError(
7+
"Repository 'octocat/nonexistent' not found. Verify the repository exists and you have access.",
8+
404,
9+
"https://api.github.com/repos/octocat/nonexistent"
10+
);
11+
12+
expect(error).toBeInstanceOf(GitHubAPIError);
13+
expect(error.statusCode).toBe(404);
14+
expect(error.url).toBe("https://api.github.com/repos/octocat/nonexistent");
15+
expect(error.message).toContain("not found");
16+
expect(error.message).toContain("octocat/nonexistent");
17+
expect(error.code).toBe("GITHUB_API_ERROR");
18+
});
19+
});
20+
21+
describe("GitHubAPIError - 401 Unauthorized", () => {
22+
it("should create error with authentication required message", () => {
23+
const error = new GitHubAPIError(
24+
"Authentication required for 'octocat/private-repo'. Provide a token with --token option.",
25+
401,
26+
"https://api.github.com/repos/octocat/private-repo"
27+
);
28+
29+
expect(error).toBeInstanceOf(GitHubAPIError);
30+
expect(error.statusCode).toBe(401);
31+
expect(error.message).toContain("Authentication required");
32+
expect(error.message).toContain("--token");
33+
});
34+
});
35+
36+
describe("GitHubAPIError - 403 Forbidden", () => {
37+
it("should create error suggesting token when no token provided", () => {
38+
const error = new GitHubAPIError(
39+
"Access forbidden to 'octocat/private-repo'. Try providing a token with --token option.",
40+
403,
41+
"https://api.github.com/repos/octocat/private-repo"
42+
);
43+
44+
expect(error).toBeInstanceOf(GitHubAPIError);
45+
expect(error.statusCode).toBe(403);
46+
expect(error.message).toContain("Access forbidden");
47+
expect(error.message).toContain("--token");
48+
});
49+
50+
it("should create error about permissions when token provided", () => {
51+
const error = new GitHubAPIError(
52+
"Access forbidden to 'octocat/private-repo'. Your token may lack required permissions.",
53+
403,
54+
"https://api.github.com/repos/octocat/private-repo"
55+
);
56+
57+
expect(error).toBeInstanceOf(GitHubAPIError);
58+
expect(error.statusCode).toBe(403);
59+
expect(error.message).toContain("permissions");
60+
});
61+
});
62+
63+
describe("GitHubAPIError - Other HTTP errors", () => {
64+
it("should create error with status code for 500 Internal Server Error", () => {
65+
const error = new GitHubAPIError(
66+
"GitHub API error (HTTP 500) for 'octocat/hello-world': Internal Server Error",
67+
500,
68+
"https://api.github.com/repos/octocat/hello-world"
69+
);
70+
71+
expect(error).toBeInstanceOf(GitHubAPIError);
72+
expect(error.statusCode).toBe(500);
73+
expect(error.message).toContain("HTTP 500");
74+
});
75+
76+
it("should create error with status code for 502 Bad Gateway", () => {
77+
const error = new GitHubAPIError(
78+
"GitHub API error (HTTP 502) for 'octocat/hello-world': Bad Gateway",
79+
502,
80+
"https://api.github.com/repos/octocat/hello-world"
81+
);
82+
83+
expect(error.statusCode).toBe(502);
84+
});
85+
});
86+
87+
describe("NetworkError - Non-HTTP failures", () => {
88+
it("should wrap network connection errors", () => {
89+
const error = new NetworkError(
90+
"Failed to connect to GitHub for 'octocat/hello-world': ECONNREFUSED",
91+
undefined,
92+
"https://api.github.com/repos/octocat/hello-world"
93+
);
94+
95+
expect(error).toBeInstanceOf(NetworkError);
96+
expect(error.code).toBe("NETWORK_ERROR");
97+
expect(error.message).toContain("Failed to connect");
98+
expect(error.message).toContain("ECONNREFUSED");
99+
expect(error.url).toBe("https://api.github.com/repos/octocat/hello-world");
100+
});
101+
102+
it("should wrap DNS resolution errors", () => {
103+
const error = new NetworkError(
104+
"Failed to connect to GitHub for 'octocat/hello-world': getaddrinfo ENOTFOUND",
105+
undefined,
106+
"https://api.github.com/repos/octocat/hello-world"
107+
);
108+
109+
expect(error).toBeInstanceOf(NetworkError);
110+
expect(error.message).toContain("ENOTFOUND");
111+
});
112+
113+
it("should wrap timeout errors", () => {
114+
const error = new NetworkError(
115+
"Failed to connect to GitHub for 'octocat/hello-world': Request timed out",
116+
undefined,
117+
"https://api.github.com/repos/octocat/hello-world"
118+
);
119+
120+
expect(error).toBeInstanceOf(NetworkError);
121+
expect(error.message).toContain("timed out");
122+
});
123+
});
124+
125+
describe("error hierarchy", () => {
126+
it("should have GitHubAPIError extend NetworkError", () => {
127+
const error = new GitHubAPIError("Test error", 404);
128+
129+
expect(error).toBeInstanceOf(Error);
130+
expect(error).toBeInstanceOf(NetworkError);
131+
expect(error).toBeInstanceOf(GitHubAPIError);
132+
});
133+
134+
it("should have proper name property for GitHubAPIError", () => {
135+
const error = new GitHubAPIError("Test error", 404);
136+
137+
expect(error.name).toBe("GitHubAPIError");
138+
});
139+
140+
it("should have proper name property for NetworkError", () => {
141+
const error = new NetworkError("Test error");
142+
143+
expect(error.name).toBe("NetworkError");
144+
});
145+
146+
it("should allow optional response data in GitHubAPIError", () => {
147+
const responseData = { documentation_url: "https://docs.github.com", message: "Not Found" };
148+
const error = new GitHubAPIError(
149+
"Repository not found",
150+
404,
151+
"https://api.github.com/repos/octocat/nonexistent",
152+
responseData
153+
);
154+
155+
expect(error.response).toEqual(responseData);
156+
});
157+
});
158+
159+
describe("error message format validation", () => {
160+
it("should include owner/repo in all error messages", () => {
161+
const errors = [
162+
new GitHubAPIError(
163+
"Repository 'facebook/react' not found. Verify the repository exists and you have access.",
164+
404,
165+
"https://api.github.com/repos/facebook/react"
166+
),
167+
new GitHubAPIError(
168+
"Authentication required for 'facebook/react'. Provide a token with --token option.",
169+
401,
170+
"https://api.github.com/repos/facebook/react"
171+
),
172+
new NetworkError(
173+
"Failed to connect to GitHub for 'facebook/react': Connection refused",
174+
undefined,
175+
"https://api.github.com/repos/facebook/react"
176+
),
177+
];
178+
179+
for (const error of errors) {
180+
expect(error.message).toContain("facebook/react");
181+
}
182+
});
183+
184+
it("should provide actionable guidance in error messages", () => {
185+
const error401 = new GitHubAPIError(
186+
"Authentication required for 'owner/repo'. Provide a token with --token option.",
187+
401,
188+
"https://api.github.com/repos/owner/repo"
189+
);
190+
191+
const error403NoToken = new GitHubAPIError(
192+
"Access forbidden to 'owner/repo'. Try providing a token with --token option.",
193+
403,
194+
"https://api.github.com/repos/owner/repo"
195+
);
196+
197+
expect(error401.message).toContain("--token");
198+
expect(error403NoToken.message).toContain("--token");
199+
});
200+
});
201+
});

src/pkg/pull/github.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,63 @@
1+
import { HTTPError } from "ky";
12
import ky from "ky";
2-
import { DEFAULT_BRANCH, GITHUB_API_URL } from "../../internal/core/types/";
3+
import { GitHubAPIError, NetworkError } from "../../internal/core/errors/";
4+
import { GITHUB_API_URL } from "../../internal/core/types/";
35
import { GitHubRepositorySchema, parseGitHubResponse } from "../../internal/infra/index.js";
46

57
export const getGitHubDefaultBranch = async (
68
owner: string,
79
repo: string,
810
token?: string
911
): Promise<string> => {
10-
try {
11-
const headers = token ? { Authorization: `token ${token}` } : {};
12-
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
12+
const headers = token ? { Authorization: `token ${token}` } : {};
13+
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
1314

15+
try {
1416
const rawData = await ky.get(repoUrl, { headers }).json();
1517
const data = parseGitHubResponse(GitHubRepositorySchema, rawData, `GET ${repoUrl}`);
1618

1719
return data.default_branch;
1820
} catch (error) {
19-
console.error(`Failed to fetch GitHub default branch for ${owner}/${repo}:`, error);
20-
return DEFAULT_BRANCH;
21+
if (error instanceof HTTPError) {
22+
const status = error.response.status;
23+
24+
if (status === 404) {
25+
throw new GitHubAPIError(
26+
`Repository '${owner}/${repo}' not found. Verify the repository exists and you have access.`,
27+
404,
28+
repoUrl
29+
);
30+
}
31+
32+
if (status === 401) {
33+
throw new GitHubAPIError(
34+
`Authentication required for '${owner}/${repo}'. Provide a token with --token option.`,
35+
401,
36+
repoUrl
37+
);
38+
}
39+
40+
if (status === 403) {
41+
throw new GitHubAPIError(
42+
token
43+
? `Access forbidden to '${owner}/${repo}'. Your token may lack required permissions.`
44+
: `Access forbidden to '${owner}/${repo}'. Try providing a token with --token option.`,
45+
403,
46+
repoUrl
47+
);
48+
}
49+
50+
throw new GitHubAPIError(
51+
`GitHub API error (HTTP ${status}) for '${owner}/${repo}': ${error.message}`,
52+
status,
53+
repoUrl
54+
);
55+
}
56+
57+
throw new NetworkError(
58+
`Failed to connect to GitHub for '${owner}/${repo}': ${error instanceof Error ? error.message : String(error)}`,
59+
undefined,
60+
repoUrl
61+
);
2162
}
2263
};

0 commit comments

Comments
 (0)