## Problem An imported issue whose bug report *is* a screenshot arrived at the agent as an unfetchable link. Everything needed to show an agent an image already existed — the executor builds a `## Attachments` section pointing at `.fusion/tasks/<id>/attachments/` (`executor.ts:18887`), triage inlines image attachments as base64 vision blocks, and `agent-prompts.ts:171` explicitly permits reading that directory. But the import routes only ever called `createTask()` with the issue body as text and never called `addAttachment()`, so **that directory was always empty for imported issues**. The images can't be fetched later by the agent: GitHub `user-attachments` assets redirect to a signed CDN URL and 404 on private repos without credentials, and GitLab `/uploads/...` needs the instance token. Import time is the only point where those credentials are known to be present. ## Change New `packages/dashboard/src/issue-image-attachments.ts` extracts images from an issue's **body and comments**, downloads them, and stores them via `addAttachment` — which already bridges images into the artifact registry, so they also surface in the UI gallery. Wired into every import surface: - `POST /github/issues/import` - `POST /github/issues/batch-import` - All four GitLab routes, via the shared `importItem` chokepoint Provider differences sit behind an `ImageImportPolicy` rather than one shared host list, because the forges disagree on what matters: | | GitHub | GitLab | |---|---|---| | URL form | absolute | usually relative `/uploads/<sha>/f.png` | | Resolution | n/a | **project**-rooted, not instance-rooted | | Trust boundary | fixed host allowlist | the configured instance origin (self-managed = any host) | | Auth | `Bearer` (gh CLI token) | `PRIVATE-TOKEN` | Notable decisions: - **Extraction runs on the original body, not the translated one.** The translation model can rewrite or drop URLs — the same reason the existing code appends `Source:` *after* translating. - **`resolve()` returning null is the SSRF guard.** It's the single place deciding a URL is ours to fetch, so `` in an issue body is never requested. - **Best-effort.** A failed download or comment fetch never fails an import that already produced the task. - **Batch stays cheap.** The REST `comments` count (free on the payload) skips the comment fetch for issues with none, so a 50-issue batch doesn't pay 50 round trips to discover empty threads. - Capped at 10 images / 5MB each (matching `MAX_ATTACHMENT_SIZE`) / 15s timeout. - `GitLabClient.listNotes` is new and **read-only** — the client's existing "no comment side effects" rule governs writes. ## Verification - **30 new/updated tests pass** (23 helper + route-level wiring on both forges). Route tests drive the real Express routes through to `addAttachment`; the helper tests alone wouldn't prove the wiring. - Typecheck clean (exit 0), lint clean, `check:changesets` passes. - **Pre-existing failures confirmed against the untouched baseline, not caused here:** 5 in `routes-github.test.ts` (`engine-unavailable`, conflict-reclaim) and the `test:gate` `chat.test.ts` mock-completeness failure both reproduce identically on `main` with this branch stashed. One incidental test fix: `routes-gitlab.test.ts` used `mockResolvedValue(jsonResponse(...))`, handing the **same** `Response` instance to every call. A `Response` body is single-use, so the added notes fetch got a consumed body. Switched to `mockImplementation` to build a fresh one per call, matching the neighbouring test. ## Notes for the reviewer Images are attached but the body's markdown links are left as-is — the agent reads the files, and rewriting URLs in operator-visible text seemed worse than leaving them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * GitHub and GitLab issue (and merge request) imports now convert embedded screenshots in issue descriptions and comments/notes into real task attachments. * Works across single-issue and batch import workflows, including project/group import flows. * **Bug Fixes** * Attachment extraction/import is resilient: per-image failures, comment/notes fetch issues, and problematic/unsafe/oversized links won’t break the overall import. * **Tests** * Added comprehensive coverage for URL extraction, provider policies, attachment downloading/limits, redirects, and route integration for both GitHub and GitLab. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
6.7 KiB
TypeScript
95 lines
6.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { GITLAB_AUTH_HEADER_NAME, type ResolvedGitlabAuth } from "../gitlab-auth.js";
|
|
import { buildGitLabTaskDescription, buildGitLabTaskProvenance, encodeGitLabPathId, GitLabClient, isGitLabAlreadyImported } from "../gitlab.js";
|
|
|
|
const auth: ResolvedGitlabAuth = {
|
|
apiBaseUrl: "https://gitlab.example.com/api/v4",
|
|
webBaseUrl: "https://gitlab.example.com",
|
|
token: "SECRET_TOKEN",
|
|
tokenType: "personal",
|
|
headerName: GITLAB_AUTH_HEADER_NAME,
|
|
};
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
describe("GitLabClient", () => {
|
|
it("encodes path identifiers with slashes but preserves numeric identifiers", () => {
|
|
expect(encodeGitLabPathId("group/sub/project")).toBe("group%2Fsub%2Fproject");
|
|
expect(encodeGitLabPathId(123)).toBe("123");
|
|
});
|
|
|
|
it("sends PRIVATE-TOKEN auth without putting tokens in errors", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([{ iid: 1, title: "Issue", description: null, web_url: "https://gitlab.example.com/g/p/-/issues/1", state: "opened", labels: [] }]));
|
|
const client = new GitLabClient(auth, fetchImpl as any);
|
|
await client.listProjectIssues("g/p", { limit: 1 });
|
|
expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues?per_page=1&page=1&state=opened");
|
|
expect(fetchImpl.mock.calls[0][1].headers["PRIVATE-TOKEN"]).toBe("SECRET_TOKEN");
|
|
|
|
fetchImpl.mockResolvedValueOnce(jsonResponse({ message: "bad SECRET_TOKEN" }, 401));
|
|
await expect(client.listProjectIssues("g/p", { limit: 1 })).rejects.toThrow("GitLab authentication failed");
|
|
});
|
|
|
|
it("posts notes and state events for issues and merge requests", async () => {
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 1 }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 2 }))
|
|
.mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "Issue", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "closed", labels: [] }))
|
|
.mockResolvedValueOnce(jsonResponse({ iid: 4, project_id: 7, title: "MR", web_url: "https://gitlab.example.com/g/p/-/merge_requests/4", state: "closed", labels: [] }));
|
|
const client = new GitLabClient(auth, fetchImpl as any);
|
|
await client.commentOnProjectIssue("g/p", 2, "done");
|
|
await client.commentOnMergeRequest(7, 4, "done");
|
|
await client.setProjectIssueState("g/p", 2, "closed");
|
|
await client.setMergeRequestState(7, 4, "closed");
|
|
expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2/notes");
|
|
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({ body: "done" }) });
|
|
expect(fetchImpl.mock.calls[1][0]).toBe("https://gitlab.example.com/api/v4/projects/7/merge_requests/4/notes");
|
|
expect(fetchImpl.mock.calls[2][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2?state_event=close");
|
|
expect(fetchImpl.mock.calls[3][0]).toBe("https://gitlab.example.com/api/v4/projects/7/merge_requests/4?state_event=close");
|
|
});
|
|
|
|
it("normalizes project issues, group issues, and merge requests", async () => {
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse([{ id: 10, iid: 2, project_id: 7, title: "Issue", description: "Body", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: ["bug"], author: { username: "ana" }, user_notes_count: 3 }]))
|
|
.mockResolvedValueOnce(jsonResponse([{ id: 11, iid: 3, project_id: 8, title: "Group issue", description: null, web_url: "https://gitlab.example.com/g/q/-/issues/3", state: "closed", labels: [] }]))
|
|
.mockResolvedValueOnce(jsonResponse([{ id: 12, iid: 4, project_id: 7, title: "MR", description: "Review", web_url: "https://gitlab.example.com/g/p/-/merge_requests/4", state: "opened", labels: ["backend"], source_branch: "feat", target_branch: "main", draft: false }]));
|
|
const client = new GitLabClient(auth, fetchImpl as any);
|
|
expect(await client.listProjectIssues(7, { limit: 1 })).toMatchObject([{ resourceKind: "project_issue", iid: 2, projectId: 7, projectPath: "g/p", commentsCount: 3 }]);
|
|
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", () => {
|
|
it("build gitlab_import source metadata and duplicate keys", () => {
|
|
const item = { resourceKind: "merge_request" as const, id: 50, iid: 9, projectId: 7, projectPath: "g/p", title: "MR", description: "", webUrl: "https://gitlab.example.com/g/p/-/merge_requests/9", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" };
|
|
const provenance = buildGitLabTaskProvenance({ auth, resourceType: "merge_request", item, projectInput: "g/p" });
|
|
expect(provenance.sourceIssue).toMatchObject({ provider: "gitlab", repository: "g/p", externalIssueId: "gitlab:mr:7:50", issueNumber: 9 });
|
|
expect(provenance.sourceMetadata).toMatchObject({ provider: "gitlab", resourceType: "merge_request", mergeRequestIid: 9, sourceBranch: "feat" });
|
|
expect(buildGitLabTaskDescription(item)).toBe("(no description)\n\nSource: https://gitlab.example.com/g/p/-/merge_requests/9");
|
|
expect(isGitLabAlreadyImported({ description: "x", sourceIssue: provenance.sourceIssue, source: { sourceType: "gitlab_import", sourceMetadata: {} } }, provenance)).toBe(true);
|
|
expect(isGitLabAlreadyImported({ description: `Source: ${item.webUrl}`, source: undefined }, provenance)).toBe(true);
|
|
});
|
|
});
|