mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
fix: bound image attachment downloads (#1625)
This commit is contained in:
@@ -34,6 +34,8 @@ const SIGNED_URL_HOST = "private-user-images.githubusercontent.com";
|
|||||||
const SIGNED_URL_PATH_REGEX =
|
const SIGNED_URL_PATH_REGEX =
|
||||||
/^\/[^/]+\/[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i;
|
/^\/[^/]+\/[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i;
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
function extractSignedUrlAssetGuid(signedUrl: string): string | undefined {
|
function extractSignedUrlAssetGuid(signedUrl: string): string | undefined {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
@@ -85,13 +87,19 @@ export type CommentWithImages =
|
|||||||
| IssueBody
|
| IssueBody
|
||||||
| PullRequestBody;
|
| PullRequestBody;
|
||||||
|
|
||||||
|
type ImageDownloadOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function downloadCommentImages(
|
export async function downloadCommentImages(
|
||||||
octokits: Octokits,
|
octokits: Octokits,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
comments: CommentWithImages[],
|
comments: CommentWithImages[],
|
||||||
|
options: ImageDownloadOptions = {},
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
const urlToPathMap = new Map<string, string>();
|
const urlToPathMap = new Map<string, string>();
|
||||||
|
const timeoutMs = options.timeoutMs ?? DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS;
|
||||||
const downloadsDir = "/tmp/github-images";
|
const downloadsDir = "/tmp/github-images";
|
||||||
|
|
||||||
await fs.mkdir(downloadsDir, { recursive: true });
|
await fs.mkdir(downloadsDir, { recursive: true });
|
||||||
@@ -241,15 +249,7 @@ export async function downloadCommentImages(
|
|||||||
try {
|
try {
|
||||||
console.log(`Downloading ${originalUrl}...`);
|
console.log(`Downloading ${originalUrl}...`);
|
||||||
|
|
||||||
const imageResponse = await fetch(signedUrl);
|
const buffer = await fetchImage(signedUrl, timeoutMs);
|
||||||
if (!imageResponse.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
// GitHub user-attachment URLs (/user-attachments/assets/<uuid>) carry
|
// GitHub user-attachment URLs (/user-attachments/assets/<uuid>) carry
|
||||||
// no file extension, so the URL-based guess silently falls back to
|
// no file extension, so the URL-based guess silently falls back to
|
||||||
@@ -289,6 +289,37 @@ export async function downloadCommentImages(
|
|||||||
return urlToPathMap;
|
return urlToPathMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchImage(url: string, timeoutMs: number): Promise<Buffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new Error(`Image download timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([
|
||||||
|
fetch(url, { signal: controller.signal }),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await Promise.race([
|
||||||
|
response.arrayBuffer(),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} finally {
|
||||||
|
if (timeoutHandle !== undefined) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getImageExtension(url: string): string {
|
function getImageExtension(url: string): string {
|
||||||
const urlParts = url.split("/");
|
const urlParts = url.split("/");
|
||||||
const filename = urlParts[urlParts.length - 1];
|
const filename = urlParts[urlParts.length - 1];
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ describe("downloadCommentImages", () => {
|
|||||||
mediaType: { format: "full+json" },
|
mediaType: { format: "full+json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
"/tmp/github-images/image-1704067200000-0.png",
|
||||||
Buffer.from(mockArrayBuffer),
|
Buffer.from(mockArrayBuffer),
|
||||||
@@ -481,8 +483,12 @@ describe("downloadCommentImages", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1);
|
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1, {
|
||||||
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2);
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
|
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2, {
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
expect(result.get(imageUrl1)).toBe(
|
expect(result.get(imageUrl1)).toBe(
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
"/tmp/github-images/image-1704067200000-0.png",
|
||||||
);
|
);
|
||||||
@@ -523,7 +529,9 @@ describe("downloadCommentImages", () => {
|
|||||||
comments,
|
comments,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
expect(result.get(imageUrl)).toBe(
|
expect(result.get(imageUrl)).toBe(
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
"/tmp/github-images/image-1704067200000-0.png",
|
||||||
);
|
);
|
||||||
@@ -766,6 +774,95 @@ describe("downloadCommentImages", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should skip an image when the fetch times out", async () => {
|
||||||
|
const mockOctokit = createMockOctokit();
|
||||||
|
const imageUrl = assetUrl(GUID_1);
|
||||||
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
||||||
|
let signal: AbortSignal | null | undefined;
|
||||||
|
|
||||||
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
body_html: `<img src="${signedUrl}">`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchSpy = spyOn(global, "fetch");
|
||||||
|
fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => {
|
||||||
|
signal = init?.signal;
|
||||||
|
return new Promise<Response>(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await downloadCommentImages(
|
||||||
|
mockOctokit,
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "issue_comment",
|
||||||
|
id: "445",
|
||||||
|
body: `Stalled image: `,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ timeoutMs: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(signal?.aborted).toBe(true);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to download"),
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Image download timed out after 5ms",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should time out while reading a response body", async () => {
|
||||||
|
const mockOctokit = createMockOctokit();
|
||||||
|
const imageUrl = assetUrl(GUID_1);
|
||||||
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
||||||
|
let signal: AbortSignal | null | undefined;
|
||||||
|
|
||||||
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
body_html: `<img src="${signedUrl}">`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchSpy = spyOn(global, "fetch");
|
||||||
|
fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => {
|
||||||
|
signal = init?.signal;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => new Promise<ArrayBuffer>(() => {}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await downloadCommentImages(
|
||||||
|
mockOctokit,
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "issue_comment",
|
||||||
|
id: "446",
|
||||||
|
body: `Stalled body: `,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ timeoutMs: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(signal?.aborted).toBe(true);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to download"),
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Image download timed out after 5ms",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("should handle API errors gracefully", async () => {
|
test("should handle API errors gracefully", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = assetUrl(GUID_1);
|
||||||
@@ -936,7 +1033,9 @@ describe("downloadCommentImages", () => {
|
|||||||
mediaType: { format: "full+json" },
|
mediaType: { format: "full+json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
"/tmp/github-images/image-1704067200000-0.png",
|
||||||
Buffer.from(mockArrayBuffer),
|
Buffer.from(mockArrayBuffer),
|
||||||
|
|||||||
Reference in New Issue
Block a user