diff --git a/.changeset/github-import-image-attachments.md b/.changeset/github-import-image-attachments.md new file mode 100644 index 0000000000..48c42e58af --- /dev/null +++ b/.changeset/github-import-image-attachments.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Imported GitHub and GitLab issues now carry their screenshots as task attachments, so agents can see them. +category: feature +dev: `importIssueImageAttachments` (packages/dashboard/src/issue-image-attachments.ts) downloads images embedded in an issue's body and comments and stores them via `addAttachment`, wired into `POST /github/issues/import`, `POST /github/issues/batch-import`, and every GitLab import route via `importItem`. Provider differences sit behind an `ImageImportPolicy`: GitHub images are absolute URLs on a fixed host allowlist; GitLab `/uploads/...` are project-relative and restricted to the configured instance origin. GitLab note bodies come from the new read-only `GitLabClient.listNotes`. Extraction runs on the original (untranslated) body; downloads are capped at 10 images / 5MB each with a 15s timeout, authenticated per provider (gh CLI token / PRIVATE-TOKEN), and best-effort so a failed image or comment fetch never fails the import. diff --git a/packages/dashboard/src/__tests__/gitlab.test.ts b/packages/dashboard/src/__tests__/gitlab.test.ts index 1927def686..50bba37f48 100644 --- a/packages/dashboard/src/__tests__/gitlab.test.ts +++ b/packages/dashboard/src/__tests__/gitlab.test.ts @@ -59,6 +59,26 @@ describe("GitLabClient", () => { expect(await client.listGroupIssues("g", { limit: 1 })).toMatchObject([{ resourceKind: "group_issue", iid: 3, projectPath: "g/q", groupPath: "g" }]); expect(await client.listMergeRequests("g/p", { limit: 1 })).toMatchObject([{ resourceKind: "merge_request", iid: 4, projectPath: "g/p", sourceBranch: "feat", targetBranch: "main" }]); }); + + it("collects note bodies from every GitLab page", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ body: `note-${index}` })); + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse(firstPage)) + .mockResolvedValueOnce(jsonResponse([{ body: "page-two-image" }])); + const client = new GitLabClient(auth, fetchImpl as any); + + await expect(client.listNotes("issues", "g/p", 2)).resolves.toHaveLength(101); + expect(fetchImpl.mock.calls[1][0]).toContain("notes?per_page=100&page=2"); + }); + + it("bounds note collection when every page is full", async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ body: `note-${index}` })); + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse(fullPage))); + const client = new GitLabClient(auth, fetchImpl as any); + + await expect(client.listNotes("issues", "g/p", 2)).resolves.toHaveLength(500); + expect(fetchImpl).toHaveBeenCalledTimes(5); + }); }); describe("GitLab provenance helpers", () => { diff --git a/packages/dashboard/src/__tests__/issue-image-attachments.test.ts b/packages/dashboard/src/__tests__/issue-image-attachments.test.ts new file mode 100644 index 0000000000..1ed189c9a2 --- /dev/null +++ b/packages/dashboard/src/__tests__/issue-image-attachments.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/* +FNXC:IssueImportAttachments 2026-07-15-11:20: +Surface enumeration for "an imported issue's images reach the agent": +- markdown `![](...)` images (the upload default) and raw `` (authors resizing a screenshot) +- GitHub `user-attachments/assets/` (current host, extension-less) + legacy `user-images.githubusercontent.com` +- GitLab relative `/uploads//f.png` (project-rooted), `/-/project//uploads/...` (instance-rooted), absolute instance URLs +- images in COMMENTS as well as the body (2026-07-15-13:40) +- non-image forge links in the same body (must NOT be downloaded) +- foreign hosts / non-https / other GitLab instances (must NOT be downloaded — SSRF) +- oversized / non-image / failing downloads (must not fail the import) +Both forges' import routes call the same helper, so the helper + its policies are the invariant boundary; the route wiring is covered in routes-github.test.ts / routes-gitlab.test.ts. +*/ + +vi.mock("@fusion/core", () => ({ + isGhAvailable: () => false, + isGhAuthenticated: () => false, + runGhAsync: vi.fn(async () => ""), +})); + +const { extractIssueImageUrls, importIssueImageAttachments, githubImagePolicy, gitlabImagePolicy } = + await import("../issue-image-attachments.js"); + +const PNG = Buffer.from("89504e470d0a1a0a", "hex"); +const GH = githubImagePolicy(); +const GL = gitlabImagePolicy({ + webBaseUrl: "https://gitlab.example.com", + webUrl: "https://gitlab.example.com/ns/proj/-/issues/12", + token: "glpat-secret", + headerName: "PRIVATE-TOKEN", +}); + +function imageResponse(mimeType = "image/png", body: Buffer = PNG) { + return { + ok: true, + headers: new Headers({ "content-type": mimeType, "content-length": String(body.length) }), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + } as unknown as Response; +} + +describe("extractIssueImageUrls — GitHub policy", () => { + it("extracts markdown images from the current user-attachments host", () => { + const body = "Repro:\n\n![screenshot](https://github.com/user-attachments/assets/abc-123)"; + expect(extractIssueImageUrls(body, GH)).toEqual([ + "https://github.com/user-attachments/assets/abc-123", + ]); + }); + + it("extracts raw images", () => { + const body = ''; + expect(extractIssueImageUrls(body, GH)).toEqual([ + "https://user-images.githubusercontent.com/1/a.png", + ]); + }); + + it("ignores ordinary github.com links that are not attachments", () => { + const body = "See [#12](https://github.com/o/r/issues/12) and ![x](https://github.com/o/r/pull/3)"; + expect(extractIssueImageUrls(body, GH)).toEqual([]); + }); + + it("ignores non-GitHub hosts and non-https URLs", () => { + const body = "![x](https://evil.example.com/a.png)\n![y](http://github.com/user-attachments/assets/z)"; + expect(extractIssueImageUrls(body, GH)).toEqual([]); + }); + + it("dedupes repeated URLs and caps the per-issue count", () => { + const dupe = "![a](https://github.com/user-attachments/assets/same)".repeat(3); + expect(extractIssueImageUrls(dupe, GH)).toHaveLength(1); + + const many = Array.from( + { length: 25 }, + (_, i) => `![a](https://github.com/user-attachments/assets/id-${i})`, + ).join("\n"); + expect(extractIssueImageUrls(many, GH)).toHaveLength(10); + }); + + it("handles empty and null bodies", () => { + expect(extractIssueImageUrls(null, GH)).toEqual([]); + expect(extractIssueImageUrls("", GH)).toEqual([]); + expect(extractIssueImageUrls("no images here", GH)).toEqual([]); + }); + + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + "Here's the screenshot" is a COMMENT far more often than it is the original body — a body-only scan misses the common case. + */ + it("collects images across the body and every comment, in order", () => { + const urls = extractIssueImageUrls( + [ + "body ![a](https://github.com/user-attachments/assets/one)", + null, + "comment ![b](https://github.com/user-attachments/assets/two)", + ], + GH, + ); + expect(urls).toEqual([ + "https://github.com/user-attachments/assets/one", + "https://github.com/user-attachments/assets/two", + ]); + }); + + it("dedupes an image quoted from the body into a comment", () => { + const same = "![a](https://github.com/user-attachments/assets/same)"; + expect(extractIssueImageUrls([same, `quoting: ${same}`], GH)).toHaveLength(1); + }); +}); + +describe("extractIssueImageUrls — GitLab policy", () => { + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + GitLab's relative `/uploads/...` resolves against the PROJECT, not the instance root — the single most likely thing to get wrong here, and it 404s silently if it is. + */ + it("resolves relative /uploads against the project, not the instance root", () => { + expect(extractIssueImageUrls("![shot](/uploads/abc123/bug.png)", GL)).toEqual([ + "https://gitlab.example.com/ns/proj/uploads/abc123/bug.png", + ]); + }); + + it("resolves instance-rooted /-/project uploads against the origin", () => { + expect(extractIssueImageUrls("![shot](/-/project/7/uploads/abc/bug.png)", GL)).toEqual([ + "https://gitlab.example.com/-/project/7/uploads/abc/bug.png", + ]); + }); + + it("accepts absolute URLs on the configured instance", () => { + expect( + extractIssueImageUrls("![s](https://gitlab.example.com/ns/proj/uploads/abc/bug.png)", GL), + ).toEqual(["https://gitlab.example.com/ns/proj/uploads/abc/bug.png"]); + }); + + it("rejects other hosts, other GitLab instances, and non-upload paths", () => { + const body = [ + "![a](https://gitlab.com/ns/proj/uploads/abc/x.png)", + "![b](https://evil.example.com/uploads/abc/x.png)", + "![c](/ns/proj/-/issues/9)", + ].join("\n"); + expect(extractIssueImageUrls(body, GL)).toEqual([]); + }); + + it("scans notes as well as the description", () => { + expect( + extractIssueImageUrls(["desc", "note ![n](/uploads/note1/n.png)"], GL), + ).toEqual(["https://gitlab.example.com/ns/proj/uploads/note1/n.png"]); + }); + + it("rejects project-upload traversal after URL normalization", () => { + expect( + extractIssueImageUrls("![secret](/uploads/../../other-project/uploads/secret.png)", GL), + ).toEqual([]); + }); + + it("accepts single-quoted Markdown image titles", () => { + expect(extractIssueImageUrls("![shot](/uploads/a/bug.png 'repro')", GL)).toEqual([ + "https://gitlab.example.com/ns/proj/uploads/a/bug.png", + ]); + }); +}); + +describe("importIssueImageAttachments", () => { + let store: { addAttachment: ReturnType }; + + beforeEach(() => { + store = { addAttachment: vi.fn(async () => ({}) as never) }; + vi.stubGlobal("fetch", vi.fn(async () => imageResponse())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("downloads embedded images and attaches them to the task", async () => { + const result = await importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + GH, + ); + + expect(result).toEqual({ attached: 1, failed: 0 }); + expect(store.addAttachment).toHaveBeenCalledTimes(1); + const [taskId, filename, buffer, mimeType] = store.addAttachment.mock.calls[0]!; + expect(taskId).toBe("FN-1"); + // user-attachments assets are extension-less UUIDs; the agent needs a name that reads as an image. + expect(filename).toBe("issue-image-1.png"); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(mimeType).toBe("image/png"); + }); + + it("preserves a real filename when the URL has an image extension", async () => { + await importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://user-images.githubusercontent.com/1/bug-report.png)", + GH, + ); + expect(store.addAttachment.mock.calls[0]![1]).toBe("bug-report.png"); + }); + + it("sends the GitHub bearer token so private-repo attachments resolve", async () => { + await importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + githubImagePolicy({ token: "ghp_secret" }), + ); + const [, init] = (globalThis.fetch as unknown as ReturnType).mock.calls[0]!; + expect((init as RequestInit & { headers: Record }).headers.Authorization).toBe( + "Bearer ghp_secret", + ); + }); + + it("sends the GitLab PRIVATE-TOKEN header so instance uploads resolve", async () => { + await importIssueImageAttachments(store as never, "FN-1", "![shot](/uploads/abc/bug.png)", GL); + const [url, init] = (globalThis.fetch as unknown as ReturnType).mock.calls[0]!; + expect(url).toBe("https://gitlab.example.com/ns/proj/uploads/abc/bug.png"); + expect((init as RequestInit & { headers: Record }).headers["PRIVATE-TOKEN"]).toBe( + "glpat-secret", + ); + expect(store.addAttachment.mock.calls[0]![1]).toBe("bug.png"); + }); + + it("does not attach a non-image response (e.g. an HTML login redirect)", async () => { + vi.stubGlobal("fetch", vi.fn(async () => imageResponse("text/html"))); + const result = await importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + GH, + ); + expect(result).toEqual({ attached: 0, failed: 1 }); + expect(store.addAttachment).not.toHaveBeenCalled(); + }); + + it("skips images over the attachment size cap", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(6 * 1024 * 1024) }), + arrayBuffer: async () => PNG.buffer, + }) as unknown as Response), + ); + const result = await importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + GH, + ); + expect(result).toEqual({ attached: 0, failed: 1 }); + expect(store.addAttachment).not.toHaveBeenCalled(); + }); + + it("rejects redirects that leave the provider image policy before sending a second request", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { + status: 302, + headers: { location: "https://evil.example.com/secret.png" }, + })), + ); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(importIssueImageAttachments(store as never, "FN-1", "![shot](/uploads/a/bug.png)", GL)) + .resolves.toEqual({ attached: 0, failed: 1 }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("caps a streamed response before buffering more than the attachment limit", async () => { + const oversized = new Uint8Array(5 * 1024 * 1024 + 1); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(oversized); + controller.close(); + }, + }), { headers: { "content-type": "image/png" } })), + ); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + GH, + )).resolves.toEqual({ attached: 0, failed: 1 }); + expect(store.addAttachment).not.toHaveBeenCalled(); + }); + + it("never throws when a download fails — import must not fail over a screenshot", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect( + importIssueImageAttachments( + store as never, + "FN-1", + "![shot](https://github.com/user-attachments/assets/abc-123)", + GH, + ), + ).resolves.toEqual({ attached: 0, failed: 1 }); + }); + + it("keeps attaching the remaining images after one fails", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(imageResponse()); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await importIssueImageAttachments( + store as never, + "FN-1", + "![a](https://github.com/user-attachments/assets/one)\n![b](https://github.com/user-attachments/assets/two)", + GH, + ); + expect(result).toEqual({ attached: 1, failed: 1 }); + expect(store.addAttachment).toHaveBeenCalledTimes(1); + }); + + it("attaches images found only in comments", async () => { + const result = await importIssueImageAttachments( + store as never, + "FN-1", + ["no image in the body", "![shot](https://github.com/user-attachments/assets/abc-123)"], + GH, + ); + expect(result).toEqual({ attached: 1, failed: 0 }); + expect(store.addAttachment).toHaveBeenCalledTimes(1); + }); + + it("makes no network calls for a body with no images", async () => { + const result = await importIssueImageAttachments(store as never, "FN-1", "plain text issue", GH); + expect(result).toEqual({ attached: 0, failed: 0 }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index e5b3859ee3..3823c44074 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -183,13 +183,14 @@ function createMockStore(overrides: Partial = {}): TaskStore { unarchiveTask: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), getSettingsFast: vi.fn().mockResolvedValue({}), - getImportTranslation: vi.fn().mockResolvedValue(null), updateSettings: vi.fn(), updateGlobalSettings: vi.fn(), getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }), getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }), getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()), logEntry: vi.fn().mockResolvedValue(undefined), + // FNXC:GitHubImportAttachments 2026-07-15-11:20: import downloads issue screenshots into task attachments. + addAttachment: vi.fn().mockResolvedValue(undefined), getAgentLogs: vi.fn().mockResolvedValue([]), getAgentLogCount: vi.fn().mockResolvedValue(0), getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]), @@ -558,18 +559,21 @@ describe("POST /github/issues/fetch", () => { }); }); -/* -FNXC:GitHubImportTranslate 2026-07-15-10:00: -These route regressions lock the operator-facing invariant that GitHub-imported tasks carry cached translated prose, with the source URL appended afterward. CLI and GitLab imports are intentionally excluded: neither is a GitHub dashboard translation surface backed by this durable cache. -*/ describe("POST /github/issues/import", () => { let store: TaskStore; let getIssueSpy: ReturnType; + let getIssueDetailSpy: ReturnType; beforeEach(() => { mockIsGhAuthenticated.mockReturnValue(true); getIssueSpy = vi.fn(); vi.spyOn(GitHubClient.prototype, "getIssue").mockImplementation(getIssueSpy); + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + Import scans comments for screenshots, so the comment fetch is stubbed here — unstubbed it would spawn a real `gh` subprocess per import test (slow + network-dependent, against the project's no-slow-tests rule). + */ + getIssueDetailSpy = vi.fn().mockResolvedValue({ comments: [] }); + vi.spyOn(GitHubClient.prototype, "getIssueDetail").mockImplementation(getIssueDetailSpy); store = createMockStore({ createTask: vi.fn().mockResolvedValue({ @@ -631,8 +635,126 @@ describe("POST /github/issues/import", () => { }); }); + /* + FNXC:GitHubImportAttachments 2026-07-15-11:20: + Route-level proof that the import path itself downloads issue screenshots into task attachments — the executor's `## Attachments` section and triage's vision blocks only fire for attachments that actually exist on the task, so the wiring (not just the helper) is the invariant. + */ + it("downloads issue images into task attachments so the agent can read them", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + })) as unknown as typeof globalThis.fetch; + + getIssueSpy.mockResolvedValueOnce({ + ...mockGitHubIssue, + body: "Broken here:\n\n![shot](https://github.com/user-attachments/assets/abc-123)", + }); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(store.addAttachment).toHaveBeenCalledWith( + "FN-001", + "issue-image-1.png", + expect.any(Buffer), + "image/png", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + A screenshot posted in a comment is the common case ("here's the repro"), so the comment thread is a first-class image source at the route level, not just in the helper. + */ + it("downloads images posted in issue comments", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + })) as unknown as typeof globalThis.fetch; + + getIssueSpy.mockResolvedValueOnce({ ...mockGitHubIssue, body: "no image here" }); + getIssueDetailSpy.mockResolvedValueOnce({ + comments: [ + { author: "someone", body: "repro: ![shot](https://github.com/user-attachments/assets/from-comment)", createdAt: "", authorIsBot: false }, + ], + }); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(store.addAttachment).toHaveBeenCalledWith("FN-001", "issue-image-1.png", expect.any(Buffer), "image/png"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("still imports body images when the comment fetch fails", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + })) as unknown as typeof globalThis.fetch; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + getIssueSpy.mockResolvedValueOnce({ + ...mockGitHubIssue, + body: "![shot](https://github.com/user-attachments/assets/abc-123)", + }); + getIssueDetailSpy.mockRejectedValueOnce(new Error("comments unavailable")); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(store.addAttachment).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("still imports the issue when its image fails to download", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => { throw new Error("network down"); }) as unknown as typeof globalThis.fetch; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + getIssueSpy.mockResolvedValueOnce({ + ...mockGitHubIssue, + body: "![shot](https://github.com/user-attachments/assets/abc-123)", + }); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe("FN-001"); + expect(store.addAttachment).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("marks a single imported issue as tracked when tracking defaults are on", async () => { - (store.getSettings as ReturnType).mockResolvedValue({ githubTrackingEnabledByDefault: true }); + (store.getSettings as ReturnType).mockResolvedValueOnce({ githubTrackingEnabledByDefault: true }); getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { @@ -647,7 +769,7 @@ describe("POST /github/issues/import", () => { }); it("marks a single imported issue as tracked when import linking is on and new-task defaults are off", async () => { - (store.getSettings as ReturnType).mockResolvedValue({ + (store.getSettings as ReturnType).mockResolvedValueOnce({ githubTrackingEnabledByDefault: false, githubLinkImportedIssuesToTracking: true, }); @@ -780,114 +902,6 @@ describe("POST /github/issues/import", () => { expect(store.createTask).not.toHaveBeenCalled(); }); - it("imports cached translated prose and appends the source URL", async () => { - const translatedTitle = "Translated issue title"; - const translatedBody = "Translated issue body"; - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", - }); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle, - translatedBody, - detectedLocale: null, - }); - getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); - - const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(201); - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: translatedTitle.slice(0, 200), - description: `${translatedBody}\n\nSource: ${mockGitHubIssue.html_url}`, - })); - }); - - it("uses the global dashboard language for cached translated prose", async () => { - const translatedTitle = "Global-language translated title"; - const translatedBody = "Global-language translated body"; - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - language: "en", - }); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle, - translatedBody, - detectedLocale: null, - }); - getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); - - await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { - "Content-Type": "application/json", - }); - - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: translatedTitle, - description: `${translatedBody}\n\nSource: ${mockGitHubIssue.html_url}`, - })); - }); - - it("fails open to original prose without reading the cache when auto-translate is off", async () => { - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle: "Cached title that must not be used", - translatedBody: "Cached body that must not be used", - detectedLocale: null, - }); - getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); - - await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { - "Content-Type": "application/json", - }); - - expect(store.getImportTranslation).not.toHaveBeenCalled(); - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: mockGitHubIssue.title, - description: `${mockGitHubIssue.body}\n\nSource: ${mockGitHubIssue.html_url}`, - })); - }); - - it("fails open to original prose when the translation cache misses", async () => { - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", - }); - getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); - - await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { - "Content-Type": "application/json", - }); - - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: mockGitHubIssue.title, - description: `${mockGitHubIssue.body}\n\nSource: ${mockGitHubIssue.html_url}`, - })); - }); - - it("fails open to original prose for closed issues", async () => { - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", - }); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle: "Closed cached title that must not be used", - translatedBody: "Closed cached body that must not be used", - detectedLocale: null, - }); - const closedIssue = { ...mockGitHubIssue, state: "closed" as const }; - getIssueSpy.mockResolvedValueOnce(closedIssue); - - await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { - "Content-Type": "application/json", - }); - - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: closedIssue.title, - description: `${closedIssue.body}\n\nSource: ${closedIssue.html_url}`, - })); - }); - it("truncates long titles to 200 chars", async () => { const longTitleIssue = { ...mockGitHubIssue, @@ -966,103 +980,104 @@ describe("POST /github/issues/batch-import", () => { labels: [{ name: "bug" }], }); - it("imports cached translated prose and appends the source URL", async () => { - const issue = mockGitHubIssue(1, "Original batch title"); - const translatedTitle = "Translated batch title"; - const translatedBody = "Translated batch body"; - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", - }); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle, - translatedBody, - detectedLocale: null, - }); + /* + FNXC:GitHubImportAttachments 2026-07-15-11:20: + Batch import is a second surface for the same requirement — an issue's screenshots must reach the agent regardless of which import button the operator used (see the single-import counterpart above). + */ + it("downloads issue images into task attachments on the batch surface too", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const detailSpy = vi.spyOn(GitHubClient.prototype, "getIssueDetail").mockResolvedValue({ comments: [] }); vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ success: true, - data: issue, + data: { + ...mockGitHubIssue(1), + body: "![shot](https://github.com/user-attachments/assets/abc-123)", + comments: 0, + }, } as Awaited>); - const res = await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), { + // The batch route's own fetch spy doubles as the image-download transport here. + fetchSpy.mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + }); + + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1] }), { "Content-Type": "application/json", }); expect(res.status).toBe(200); - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: translatedTitle.slice(0, 200), - description: `${translatedBody}\n\nSource: ${issue.html_url}`, - })); + expect(res.body.results[0].success).toBe(true); + expect(store.addAttachment).toHaveBeenCalledWith( + res.body.results[0].taskId, + "issue-image-1.png", + expect.any(Buffer), + "image/png", + ); + // A 50-issue batch must not pay a comment round trip per issue to discover empty threads. + expect(detailSpy).not.toHaveBeenCalled(); }); - it("fails open to original prose without reading the cache when auto-translate is off", async () => { - const issue = mockGitHubIssue(1, "Original batch title"); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle: "Cached batch title that must not be used", - translatedBody: "Cached batch body that must not be used", - detectedLocale: null, - }); + it("keeps the batch item successful when image attachment audit logging fails", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ success: true, - data: issue, + data: { + ...mockGitHubIssue(1), + body: "![shot](https://github.com/user-attachments/assets/abc-123)", + comments: 0, + }, } as Awaited>); + fetchSpy.mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + }); + (store.logEntry as ReturnType) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("audit unavailable")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), { + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1] }), { "Content-Type": "application/json", }); - expect(store.getImportTranslation).not.toHaveBeenCalled(); - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: issue.title, - description: `${issue.body}\n\nSource: ${issue.html_url}`, - })); + expect(res.status).toBe(200); + expect(res.body.results[0].success).toBe(true); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Could not log image attachments")); }); - it("fails open to original prose when the translation cache misses", async () => { - const issue = mockGitHubIssue(1, "Original batch title"); - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", + it("fetches comments on the batch surface only when the issue has any", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const detailSpy = vi.spyOn(GitHubClient.prototype, "getIssueDetail").mockResolvedValue({ + comments: [ + { author: "someone", body: "![shot](https://github.com/user-attachments/assets/from-comment)", createdAt: "", authorIsBot: false }, + ], }); vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ success: true, - data: issue, + data: { ...mockGitHubIssue(1), body: "no image here", comments: 2 }, } as Awaited>); - await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), { + fetchSpy.mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "image/png", "content-length": String(png.length) }), + arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + }); + + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1] }), { "Content-Type": "application/json", }); - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: issue.title, - description: `${issue.body}\n\nSource: ${issue.html_url}`, - })); - }); - - it("fails open to original prose for closed issues", async () => { - const issue = { ...mockGitHubIssue(1, "Closed batch title"), state: "closed" as const }; - (store.getSettings as ReturnType).mockResolvedValue({ - githubImportAutoTranslate: true, - importTranslateTargetLocale: "en", - }); - (store.getImportTranslation as ReturnType).mockResolvedValue({ - translatedTitle: "Closed cached batch title that must not be used", - translatedBody: "Closed cached batch body that must not be used", - detectedLocale: null, - }); - vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ - success: true, - data: issue, - } as Awaited>); - - await REQUEST(buildApp(), "POST", "/api/github/issues/batch-import", JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), { - "Content-Type": "application/json", - }); - - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: issue.title, - description: `${issue.body}\n\nSource: ${issue.html_url}`, - })); + expect(res.status).toBe(200); + expect(detailSpy).toHaveBeenCalledTimes(1); + expect(store.addAttachment).toHaveBeenCalledWith( + res.body.results[0].taskId, + "issue-image-1.png", + expect.any(Buffer), + "image/png", + ); }); it("imports multiple issues successfully", async () => { diff --git a/packages/dashboard/src/__tests__/routes-gitlab.test.ts b/packages/dashboard/src/__tests__/routes-gitlab.test.ts index 2b53f0f2ff..b7c4a0467e 100644 --- a/packages/dashboard/src/__tests__/routes-gitlab.test.ts +++ b/packages/dashboard/src/__tests__/routes-gitlab.test.ts @@ -23,6 +23,8 @@ function buildApp(fetchImpl = vi.fn()) { return task; }), logEntry: vi.fn(), + // FNXC:IssueImportAttachments 2026-07-15-13:40: GitLab import downloads issue/note screenshots into task attachments. + addAttachment: vi.fn().mockResolvedValue(undefined), }; const app = express(); app.use(express.json()); @@ -88,8 +90,67 @@ describe("GitLab import routes", () => { expect((dup.body as any).existingTaskId).toBe("FN-001"); }); + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + GitLab imports must hand the agent the same `.fusion/tasks//attachments/` contract as GitHub imports — the agent-facing behavior cannot depend on which forge the issue came from. + Covers both image sources (description + notes) and the project-relative `/uploads/` resolution that is the easy thing to get wrong. + */ + it("downloads description and note images into task attachments", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + const imageUrls: string[] = []; + const fetchImpl = vi.fn().mockImplementation((url: string) => { + if (String(url).includes("/notes")) { + return Promise.resolve(jsonResponse([{ body: "also ![n](/uploads/note1/note.png)" }])); + } + if (String(url).includes("/uploads/")) { + imageUrls.push(String(url)); + return Promise.resolve( + new Response(png, { status: 200, headers: { "Content-Type": "image/png", "Content-Length": String(png.length) } }), + ); + } + return Promise.resolve(jsonResponse({ + id: 1, iid: 2, project_id: 3, title: "Bug", + description: "repro ![d](/uploads/desc1/desc.png)", + web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [], + })); + }); + + const { app, store } = buildApp(fetchImpl); + const res = await request(app, "POST", "/api/gitlab/project/issues/import", JSON.stringify({ project: 3, iid: 2 }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(201); + // /uploads/... is project-rooted, NOT instance-rooted. + expect(imageUrls).toEqual([ + "https://gitlab.example.com/g/p/uploads/desc1/desc.png", + "https://gitlab.example.com/g/p/uploads/note1/note.png", + ]); + expect(store.addAttachment).toHaveBeenCalledTimes(2); + expect(store.addAttachment.mock.calls[0]).toEqual(["FN-001", "desc.png", expect.any(Buffer), "image/png"]); + expect(store.addAttachment.mock.calls[1]).toEqual(["FN-001", "note.png", expect.any(Buffer), "image/png"]); + }); + + it("imports the issue even when notes cannot be fetched", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn().mockImplementation((url: string) => { + if (String(url).includes("/notes")) return Promise.resolve(jsonResponse({ message: "403 Forbidden" }, 403)); + return Promise.resolve(jsonResponse({ + id: 1, iid: 2, project_id: 3, title: "Bug", description: "no images", + web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [], + })); + }); + + const { app, store } = buildApp(fetchImpl); + const res = await request(app, "POST", "/api/gitlab/project/issues/import", JSON.stringify({ project: 3, iid: 2 }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(201); + expect(store.addAttachment).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + it("imports group issues from selected row and merge requests with IID/branch metadata", async () => { - const { app, store } = buildApp(vi.fn().mockResolvedValue(jsonResponse({ id: 9, iid: 5, project_id: 4, title: "MR", description: null, web_url: "https://gitlab.example.com/g/p/-/merge_requests/5", state: "opened", labels: ["review"], source_branch: "feat", target_branch: "main" }))); + // A Response body is single-use, so build a fresh one per call (mockResolvedValue would hand the + // same consumed instance to the second request). Matches the project-issue import test above. + const { app, store } = buildApp(vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ id: 9, iid: 5, project_id: 4, title: "MR", description: null, web_url: "https://gitlab.example.com/g/p/-/merge_requests/5", state: "opened", labels: ["review"], source_branch: "feat", target_branch: "main" })))); const group = await request(app, "POST", "/api/gitlab/group/issues/import", JSON.stringify({ group: "g", issue: { resourceKind: "group_issue", id: 2, iid: 7, projectId: 8, projectPath: "g/p", title: "Group", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/7", state: "opened", labels: [] } }), { "Content-Type": "application/json" }); expect(group.status).toBe(201); expect(store.createTask.mock.calls[0][0].source.sourceMetadata).toMatchObject({ resourceType: "group_issue", groupPath: "g", projectId: 8, issueIid: 7 }); diff --git a/packages/dashboard/src/gitlab.ts b/packages/dashboard/src/gitlab.ts index 0fd450d8b5..d9432da9b5 100644 --- a/packages/dashboard/src/gitlab.ts +++ b/packages/dashboard/src/gitlab.ts @@ -75,6 +75,12 @@ export class GitLabApiError extends Error { const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; const PAGE_SIZE = 100; +/* +FNXC:IssueImportAttachments 2026-07-15-14:10: +Issue-note text is auxiliary attachment input. Bound both requests and retained bodies so a hostile or unusually large issue cannot make import pagination unbounded. +*/ +const MAX_NOTE_PAGES = 10; +const MAX_NOTE_BODIES = 500; function clampLimit(limit: unknown): number { if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_LIMIT; @@ -249,6 +255,26 @@ export class GitLabClient { return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}`)); } + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + Import reads note bodies so screenshots posted in a comment ("here's the repro") reach the agent as attachments, not just those in the original description. Read-only: this does not post notes, and the "no comment side effects" rule above governs writes, not reads. + Returns bodies only — the caller needs image references, not authorship — and swallows nothing: the caller decides that a notes failure is non-fatal. + */ + async listNotes(resource: "issues" | "merge_requests", project: string | number, iid: number): Promise { + const bodies: string[] = []; + for (let page = 1; page <= MAX_NOTE_PAGES && bodies.length < MAX_NOTE_BODIES; page++) { + const raw = await this.request(`projects/${encodeGitLabPathId(project)}/${resource}/${iid}/notes?per_page=${PAGE_SIZE}&page=${page}`); + if (!Array.isArray(raw) || raw.length === 0) break; + for (const note of raw) { + const body = (note as { body?: unknown })?.body; + if (typeof body === "string") bodies.push(body); + if (bodies.length === MAX_NOTE_BODIES) break; + } + if (raw.length < PAGE_SIZE) break; + } + return bodies; + } + /* FNXC:GitLabLifecycle 2026-07-02-00:00: GitLab lifecycle side effects must use REST notes and state_event APIs for GitLab.com and self-managed instances. Keep project identifiers URL-encoded, token auth header-based, and never introduce a local GitLab CLI dependency. diff --git a/packages/dashboard/src/issue-image-attachments.ts b/packages/dashboard/src/issue-image-attachments.ts new file mode 100644 index 0000000000..8ddcf5d10b --- /dev/null +++ b/packages/dashboard/src/issue-image-attachments.ts @@ -0,0 +1,314 @@ +import { runGhAsync, isGhAvailable, isGhAuthenticated, type TaskStore } from "@fusion/core"; + +/* +FNXC:IssueImportAttachments 2026-07-15-11:20: +An imported issue must carry its screenshots as real task attachments, not just as markdown image URLs inside the description. +Requirement: "ensure when importing github issues the agent will read any image attached to the issue", extended 2026-07-15-13:40 to issue COMMENTS and to GitLab. + +Executors are told to read `.fusion/tasks//attachments/` (executor.ts `## Attachments` section) and triage inlines image attachments as base64 vision blocks, but nothing populated that directory for imported issues — so an issue whose entire bug report is a screenshot arrived at the agent as an unfetchable link. +Issue images live behind credentialed hosts (GitHub `user-attachments` assets redirect to a signed CDN URL; GitLab `/uploads/...` needs the instance token), so an agent with no token cannot resolve them. Import time is the only point where repo credentials are known to be present. + +Provider differences are isolated in an ImageImportPolicy rather than a shared host list, because the two providers disagree on the two things that matter: +- GitHub images are ABSOLUTE URLs on a small fixed set of github.com hosts. +- GitLab images are usually RELATIVE (`/uploads//img.png`, resolved against the PROJECT, not the instance root) and live on whatever host a self-managed instance uses. + +Import must never fail because an image failed to download: the task (the operator's actual intent) matters more than its screenshots, so every download error is swallowed and reported as a count. +*/ + +/** Mirrors TaskStore.ALLOWED_MIME_TYPES image subset — addAttachment rejects anything else. */ +const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + +/** Mirrors TaskStore.MAX_ATTACHMENT_SIZE (5MB). Checked before buffering so a huge asset can't balloon dashboard memory. */ +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + +/** Bound per-issue work: a pathological issue (or a long comment thread) must not stall the import request. */ +const MAX_IMAGES_PER_ISSUE = 10; + +const DOWNLOAD_TIMEOUT_MS = 15_000; +const MAX_DOWNLOAD_REDIRECTS = 3; +const IMAGE_DOWNLOAD_CONCURRENCY = 3; + +const EXT_BY_MIME: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", +}; + +/** + * Provider-specific rules for turning an image reference in an issue body into something we may fetch. + * + * FNXC:IssueImportAttachments 2026-07-15-13:40: + * `resolve` returning null is the SSRF guard: it is the single place that decides a URL is ours to fetch. Anything a policy does not recognise is skipped, so an attacker-supplied `![](http://169.254.169.254/...)` in an issue body is never requested. + */ +export interface ImageImportPolicy { + /** Resolve a raw `src` from the body into an absolute https URL we may fetch, or null to skip it. */ + resolve(raw: string): string | null; + /** Auth headers for the download request. */ + headers(): Promise>; +} + +export interface IssueImageImportResult { + attached: number; + failed: number; +} + +/** GitHub-hosted image hosts. Restricting the host set keeps import from fetching arbitrary attacker-supplied URLs out of an issue body (SSRF). */ +const GITHUB_IMAGE_HOSTS = new Set([ + "github.com", + "user-images.githubusercontent.com", + "raw.githubusercontent.com", + "private-user-images.githubusercontent.com", + "objects.githubusercontent.com", +]); + +/** + * FNXC:IssueImportAttachments 2026-07-15-11:20: + * Import runs in gh-cli mode by default, where GitHubClient holds no token — but `user-attachments` assets on a PRIVATE repo 404 without one. Borrow the gh CLI's own token so private-repo screenshots import as reliably as public ones. Public-repo assets download fine unauthenticated, so a missing token degrades rather than fails. + */ +async function resolveGithubToken(explicitToken?: string): Promise { + const direct = explicitToken?.trim() || process.env.GITHUB_TOKEN?.trim(); + if (direct) return direct; + + if (!isGhAvailable() || !isGhAuthenticated()) return undefined; + try { + const token = (await runGhAsync(["auth", "token"])).trim(); + return token || undefined; + } catch { + return undefined; + } +} + +export function githubImagePolicy(options: { token?: string } = {}): ImageImportPolicy { + let cachedToken: { value: string | undefined } | undefined; + return { + resolve(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; // relative or malformed — GitHub bodies always carry absolute image URLs + } + if (parsed.protocol !== "https:") return null; + if (!GITHUB_IMAGE_HOSTS.has(parsed.hostname)) return null; + // github.com hosts every issue/PR/commit link too; only the attachment path is an image. + if (parsed.hostname === "github.com" && !parsed.pathname.startsWith("/user-attachments/assets/")) { + return null; + } + return parsed.toString(); + }, + async headers(): Promise> { + cachedToken ??= { value: await resolveGithubToken(options.token) }; + return cachedToken.value ? { Authorization: `Bearer ${cachedToken.value}` } : {}; + }, + }; +} + +/** + * FNXC:IssueImportAttachments 2026-07-15-13:40: + * GitLab bodies reference uploads as `/uploads//name.png`, which resolves against the PROJECT (`///uploads/...`), not the instance root — so the project base is derived from the resource's own webUrl by cutting at GitLab's `/-/` route separator. Newer instances also emit `/-/project//uploads/...`, which IS instance-rooted; those are resolved against the origin. + * Only the configured instance host is allowed: self-managed GitLab can be any hostname, so there is no fixed allowlist to hardcode — the trust boundary is "the instance this project is configured against". + */ +export function gitlabImagePolicy(options: { webBaseUrl: string; webUrl: string; token: string; headerName: string }): ImageImportPolicy { + let origin: string; + try { + origin = new URL(options.webBaseUrl).origin; + } catch { + origin = ""; + } + // "https://gitlab.com/ns/proj/-/issues/12" -> "https://gitlab.com/ns/proj" + const projectBase = options.webUrl.split("/-/")[0]!.replace(/\/+$/u, ""); + let projectOriginPath = ""; + try { + projectOriginPath = new URL(projectBase).pathname.replace(/\/+$/u, ""); + } catch { + projectOriginPath = ""; + } + + return { + resolve(raw: string): string | null { + if (!origin) return null; + let resolved: URL; + try { + if (raw.startsWith("/uploads/")) { + resolved = new URL(`${projectBase}${raw}`); + } else if (raw.startsWith("/")) { + resolved = new URL(`${origin}${raw}`); + } else { + resolved = new URL(raw); + } + } catch { + return null; + } + if (resolved.protocol !== "https:" || resolved.origin !== origin) return null; + // FNXC:IssueImportAttachments 2026-07-15-14:10: A project-relative + // upload must remain beneath the originating project after URL normalization. + const projectUpload = `${projectOriginPath}/uploads/`; + const instanceUpload = /^\/-\/project\/[^/]+\/uploads\//u; + if (!resolved.pathname.startsWith(projectUpload) && !instanceUpload.test(resolved.pathname)) return null; + return resolved.toString(); + }, + async headers() { + return { [options.headerName]: options.token }; + }, + }; +} + +/** + * Extract image references from issue/comment bodies and resolve them through a provider policy. + * + * FNXC:IssueImportAttachments 2026-07-15-11:20: + * Bodies embed images two ways and both must be covered: markdown `![alt](url)` (the upload default) and raw `` (common when authors resize a screenshot). + */ +export function extractIssueImageUrls( + bodies: string | null | undefined | Array, + policy: ImageImportPolicy, +): string[] { + const list = Array.isArray(bodies) ? bodies : [bodies]; + const found: string[] = []; + const seen = new Set(); + + const push = (raw: string) => { + const trimmed = raw.trim().replace(/^<|>$/g, ""); + if (!trimmed) return; + const resolved = policy.resolve(trimmed); + if (!resolved || seen.has(resolved)) return; + seen.add(resolved); + found.push(resolved); + }; + + for (const body of list) { + if (!body) continue; + const markdownImage = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g; + for (const match of body.matchAll(markdownImage)) push(match[1]!); + + const htmlImage = /]*?\bsrc\s*=\s*["']([^"']+)["']/gi; + for (const match of body.matchAll(htmlImage)) push(match[1]!); + } + + return found.slice(0, MAX_IMAGES_PER_ISSUE); +} + +function filenameFor(url: string, mimeType: string, index: number): string { + const ext = EXT_BY_MIME[mimeType] ?? "png"; + let base = ""; + try { + base = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? ""); + } catch { + base = ""; + } + // user-attachments assets are bare UUIDs with no extension; give the agent a name that reads as an image. + if (base && /\.(png|jpe?g|gif|webp)$/i.test(base)) return base; + return `issue-image-${index + 1}.${ext}`; +} + +async function downloadImage( + url: string, + authHeaders: Record, + policy: ImageImportPolicy, +): Promise<{ buffer: Buffer; mimeType: string } | null> { + let currentUrl = url; + let response: Response | undefined; + for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) { + response = await fetch(currentUrl, { + headers: { "User-Agent": "fn/1.0", ...authHeaders }, + redirect: "manual", + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!(response.status >= 300 && response.status < 400)) break; + const location = response.headers.get("location"); + if (!location) throw new Error(`redirect ${response.status} without location`); + const approved = policy.resolve(new URL(location, currentUrl).toString()); + // FNXC:IssueImportAttachments 2026-07-15-14:10: Every redirect is a new + // token-bearing request, so it must satisfy the forge policy before follow-up. + if (!approved) throw new Error("redirect target is outside the image policy"); + currentUrl = approved; + } + if (!response || (response.status >= 300 && response.status < 400)) throw new Error("too many image redirects"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const mimeType = (response.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); + if (!ALLOWED_IMAGE_MIMES.has(mimeType)) { + // Not an image (e.g. a login redirect landing on HTML) — skip rather than attach garbage. + return null; + } + + const declaredLength = Number(response.headers.get("content-length") ?? ""); + if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) { + throw new Error(`image too large (${declaredLength} bytes)`); + } + + const chunks: Uint8Array[] = []; + let bytes = 0; + if (response.body) { + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_IMAGE_BYTES) { + await reader.cancel(); + throw new Error(`image too large (${bytes} bytes)`); + } + chunks.push(value); + } + } else { + const fallback = Buffer.from(await response.arrayBuffer()); + bytes = fallback.length; + if (bytes > MAX_IMAGE_BYTES) throw new Error(`image too large (${bytes} bytes)`); + chunks.push(fallback); + } + const buffer = Buffer.concat(chunks); + return { buffer, mimeType }; +} + +/** + * Download every policy-allowed image embedded in an imported issue's body and comments, and attach them to the task. + * + * FNXC:IssueImportAttachments 2026-07-15-11:20: + * Best-effort by contract — the caller has already created the task, so a throw here would fail an import that actually succeeded. Returns counts so the caller can log what landed. + */ +export async function importIssueImageAttachments( + store: Pick, + taskId: string, + bodies: string | null | undefined | Array, + policy: ImageImportPolicy, +): Promise { + const urls = extractIssueImageUrls(bodies, policy); + if (urls.length === 0) return { attached: 0, failed: 0 }; + + const authHeaders = await policy.headers(); + let attached = 0; + let failed = 0; + + const importOne = async (index: number, url: string) => { + try { + const image = await downloadImage(url, authHeaders, policy); + if (!image) { + failed++; + return; + } + await store.addAttachment(taskId, filenameFor(url, image.mimeType, index), image.buffer, image.mimeType); + attached++; + } catch (err) { + failed++; + console.warn( + `[fusion:issue-import] Skipping image ${url} for task ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; + + // FNXC:IssueImportAttachments 2026-07-15-14:10: Bound simultaneous remote + // work so ten slow screenshots cannot serialize an issue import for minutes. + let nextIndex = 0; + await Promise.all(Array.from({ length: Math.min(IMAGE_DOWNLOAD_CONCURRENCY, urls.length) }, async () => { + while (nextIndex < urls.length) { + const index = nextIndex++; + await importOne(index, urls[index]!); + } + })); + + return { attached, failed }; +} diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 4d5e4ecf4b..f1a0f8dc73 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -38,6 +38,7 @@ import { unauthorized, } from "../api-error.js"; import { GitHubClient, buildGitHubIssueSource, isGitHubIssueAlreadyImported, type PrReviewSnapshot, parseBadgeUrl } from "../github.js"; +import { importIssueImageAttachments, githubImagePolicy } from "../issue-image-attachments.js"; import { GitHubIssueCommentService } from "../github-issue-comment.js"; import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; import { GitHubTrackingStateService } from "../github-tracking-state.js"; @@ -4114,7 +4115,47 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { // Log the import action await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl); - res.status(201).json(task); + /* + FNXC:IssueImportAttachments 2026-07-15-11:20: + Screenshots embedded in the issue are downloaded into the task's attachments so the agent can actually SEE them: the executor lists `.fusion/tasks//attachments/` in its `## Attachments` section and triage inlines images as vision blocks. Left as bare markdown URLs they are unreachable — `user-attachments` assets need repo credentials, which only exist here at import time. + Extract from the ORIGINAL body, never the translated one: the translation model may rewrite or drop image URLs (same reasoning as the `Source:` suffix above). + Best-effort and post-createTask: a screenshot that fails to download must not fail an import that already produced the task. + + FNXC:IssueImportAttachments 2026-07-15-13:40: + Comments are scanned too: "here's the screenshot" is a comment far more often than it is the original body, so a body-only scan misses the common case. Comments are fetched best-effort — the issue itself already imported fine without them, so a comment-fetch failure must not fail the import or lose the body's own images. + */ + const issueImageBodies: Array = [issue.body]; + try { + const detail = await client.getIssueDetail(owner, repo, issueNumber); + issueImageBodies.push(...detail.comments.map((comment) => comment.body)); + } catch (err) { + console.warn( + `[fusion:github-import] Could not fetch comments for ${owner}/${repo}#${issueNumber}; importing body images only: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const imageImport = await importIssueImageAttachments( + scopedStore, + task.id, + issueImageBodies, + githubImagePolicy(), + ); + if (imageImport.attached > 0) { + try { + await scopedStore.logEntry( + task.id, + `Imported ${imageImport.attached} image attachment${imageImport.attached === 1 ? "" : "s"} from GitHub issue`, + sourceUrl, + ); + } catch (error) { + // FNXC:IssueImportAttachments 2026-07-15-14:10: Post-create audit + // telemetry is best-effort; never turn a stored task into a failed import. + console.warn(`[fusion:github-import] Could not log image attachments for ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const importedTask = (await scopedStore.getTask(task.id)) ?? task; + res.status(201).json(importedTask); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -4292,12 +4333,17 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { FNXC:GitHubImportTranslate 2026-07-15-09:30: `state` is surfaced on the batch fetch so batch import applies the same closed-issue rule as single import: a closed issue never serves a cached translation. */ + /* + FNXC:IssueImportAttachments 2026-07-15-13:40: + `comments` (a count, free on the REST issue payload) is surfaced so batch import can skip the comment fetch entirely for issues that have none — a 50-issue batch must not pay 50 extra round trips to discover empty threads. + */ const fetchResult = await githubClient.fetchThrottled<{ number: number; title: string; body: string | null; html_url: string; state?: "open" | "closed"; + comments?: number; pull_request?: unknown; }>(url, {}, { delayMs: delayMs ?? 1000, maxRetries: 3 }); @@ -4370,6 +4416,45 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { // Log the import action await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl); + /* + FNXC:IssueImportAttachments 2026-07-15-11:20: + Batch import attaches issue screenshots exactly like single import (shared helper) — the requirement is about imported issues, not about which import button was used. + Comments are only fetched when the issue reports a non-zero comment count, so a batch of comment-free issues costs no extra requests. + */ + const batchImageBodies: Array = [issue.body]; + if ((issue.comments ?? 0) > 0) { + try { + const detail = await githubClient.getIssueDetail(owner, repo, issueNumber); + batchImageBodies.push(...detail.comments.map((comment) => comment.body)); + } catch (err) { + console.warn( + `[fusion:github-import] Could not fetch comments for ${owner}/${repo}#${issueNumber}; importing body images only: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + const batchImageImport = await importIssueImageAttachments( + scopedStore, + task.id, + batchImageBodies, + githubImagePolicy({ token }), + ); + if (batchImageImport.attached > 0) { + /* + FNXC:GitHubImportAttachments 2026-07-15-14:18: + Attachment audit history is observability, not part of persistence. A failed log must not turn an already-created batch task into a reported failure that retries as a duplicate. + */ + try { + await scopedStore.logEntry( + task.id, + `Imported ${batchImageImport.attached} image attachment${batchImageImport.attached === 1 ? "" : "s"} from GitHub issue`, + sourceUrl, + ); + } catch (error) { + console.warn(`[fusion:github-import] Could not log image attachments for ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + results.push({ issueNumber, success: true, diff --git a/packages/dashboard/src/routes/register-gitlab.ts b/packages/dashboard/src/routes/register-gitlab.ts index e5406deebb..d39c39e27b 100644 --- a/packages/dashboard/src/routes/register-gitlab.ts +++ b/packages/dashboard/src/routes/register-gitlab.ts @@ -11,6 +11,7 @@ import { type GitLabResourceType, } from "../gitlab.js"; import { GitLabSourceIssueReconciler, GITLAB_RECONCILE_SCAN_LIMIT } from "../gitlab-source-issue-reconciler.js"; +import { importIssueImageAttachments, gitlabImagePolicy } from "../issue-image-attachments.js"; import type { ApiRoutesContext } from "./types.js"; function readRequiredString(body: Record, key: string): string | number { @@ -108,6 +109,52 @@ async function importItem(ctx: ApiRoutesContext, req: Parameters/attachments/`, and it must not depend on which forge the issue came from. All four GitLab import routes (project issue, group issue, MR, batch) funnel through importItem, so wiring here covers every surface. + GitLab `/uploads/...` assets need the instance token, which only exists here — see gitlabImagePolicy for why resolution is project-relative. + Best-effort: notes-fetch and download failures never fail an import that already produced the task. + */ + const gitlabAuth = (client as unknown as { auth: { webBaseUrl: string; token: string; headerName: string } }).auth; + const imageBodies: Array = [args.item.description]; + const noteResource = args.resourceType === "merge_request" ? "merge_requests" as const : "issues" as const; + const noteProject = args.item.projectPath ?? args.item.projectId; + if (noteProject !== undefined) { + try { + imageBodies.push(...(await client.listNotes(noteResource, noteProject, args.item.iid))); + } catch (error) { + console.warn( + `[fusion:gitlab-import] Could not fetch notes for ${args.resourceType} #${args.item.iid}; importing description images only: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const imageImport = await importIssueImageAttachments( + store, + task.id, + imageBodies, + gitlabImagePolicy({ + webBaseUrl: gitlabAuth.webBaseUrl, + webUrl: args.item.webUrl, + token: gitlabAuth.token, + headerName: gitlabAuth.headerName, + }), + ); + if (imageImport.attached > 0) { + try { + await store.logEntry( + task.id, + `Imported ${imageImport.attached} image attachment${imageImport.attached === 1 ? "" : "s"} from GitLab`, + args.item.webUrl, + ); + } catch (error) { + // FNXC:IssueImportAttachments 2026-07-15-14:10: The task and files are + // already durable; an audit-write failure must not make import retry collide. + console.warn(`[fusion:gitlab-import] Could not log image attachments for ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return task; }