Files
fusion/packages/dashboard/src/__tests__/gitlab.test.ts
gsxdsm 203557e7ae feat(dashboard): attach issue images on GitHub and GitLab import (#2151)
## 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
`![](http://169.254.169.254/...)` 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>
2026-07-15 21:58:46 -07:00

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);
});
});