Files
fusion/packages/cli/src/__tests__/task-command-gitlab-import.test.ts
gsxdsm 865dec235b FN-7424: add GitLab task imports
Adds GitLab-backed task import flows across the CLI, extension, API, and dashboard.

- Add GitLab client normalization, provenance, duplicate detection, and import routes for project issues, group issues, and merge requests.
- Extend the dashboard import modal with a GitLab provider, resource tabs, previews, imported-state detection, and import actions.
- Add CLI and extension task import commands plus usage event/gating classifications and operator documentation.
- Cover GitLab fetch/import behavior with dashboard, CLI, and gating tests.

Files changed:
 .changeset/fn-7424-gitlab-imports.md               |   7 +
 docs/cli-reference.md                              |  11 +-
 docs/gitlab-parity-inventory.md                    |  10 +-
 docs/task-management.md                            |   6 +-
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |  60 ++++
 .../skill/fusion/references/fusion-capabilities.md |   6 +
 packages/cli/src/__tests__/extension.test.ts       |   6 +
 .../__tests__/task-command-gitlab-import.test.ts   |  97 ++++++
 packages/cli/src/bin.ts                            |  25 +-
 packages/cli/src/commands/task.ts                  |  58 ++++
 packages/cli/src/extension.ts                      | 106 +++++++
 packages/core/src/__tests__/usage-events.test.ts   |   2 +
 packages/core/src/types.ts                         |   7 +
 packages/core/src/usage-events.ts                  |   3 +
 packages/dashboard/app/api/legacy.ts               |  52 ++++
 .../dashboard/app/components/GitHubImportModal.css |  41 +++
 .../dashboard/app/components/GitHubImportModal.tsx | 143 ++++++++-
 .../__tests__/GitHubImportModal.test.tsx           |  33 ++
 packages/dashboard/src/__tests__/gitlab.test.ts    |  56 ++++
 .../dashboard/src/__tests__/routes-gitlab.test.ts  |  99 ++++++
 packages/dashboard/src/gitlab.ts                   | 334 +++++++++++++++++++++
 packages/dashboard/src/index.ts                    |  15 +
 packages/dashboard/src/routes.ts                   |   2 +
 packages/dashboard/src/routes/register-gitlab.ts   | 192 ++++++++++++
 .../engine/src/__tests__/agent-action-gate.test.ts |   4 +
 .../gating-classifications-provisioning.test.ts    |  13 +-
 .../src/__tests__/gating-classifications.test.ts   |   6 +
 packages/engine/src/gating-classifications.ts      |   9 +
 29 files changed, 1388 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7424
Fusion-Task-Lineage: 8012425c-21d5-4b20-adb7-07d2e5aa1cef
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 11:38:34 -07:00

98 lines
5.0 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const mocks = vi.hoisted(() => ({
createTask: vi.fn(),
logEntry: vi.fn(),
listTasks: vi.fn(),
getSettings: vi.fn(),
getGlobalSettings: vi.fn(),
}));
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
TaskStore: vi.fn(function TaskStore() {
return {
init: vi.fn(),
getSettings: mocks.getSettings,
getGlobalSettingsStore: () => ({ getSettings: mocks.getGlobalSettings }),
listTasks: mocks.listTasks,
createTask: mocks.createTask,
logEntry: mocks.logEntry,
};
}),
};
});
vi.mock("@fusion/dashboard", () => {
class GitLabClient {
auth: any;
constructor(auth: any) { this.auth = auth; }
async listProjectIssues(project: string, options: any = {}) {
const response = await fetch(`https://gitlab.example.com/api/v4/projects/${encodeURIComponent(project)}/issues?per_page=${options.limit ?? 30}&page=1&state=opened`);
return response.json();
}
async listGroupIssues(group: string, options: any = {}) {
const response = await fetch(`https://gitlab.example.com/api/v4/groups/${encodeURIComponent(group)}/issues?per_page=${options.limit ?? 30}&page=1&state=opened`);
return response.json();
}
async listMergeRequests(project: string, options: any = {}) {
const response = await fetch(`https://gitlab.example.com/api/v4/projects/${encodeURIComponent(project)}/merge_requests?per_page=${options.limit ?? 30}&page=1&state=opened`);
return response.json();
}
}
return {
registerGithubTrackingHook: vi.fn(),
resolveGitlabAuth: vi.fn(() => ({ ok: true, auth: { apiBaseUrl: "https://gitlab.example.com/api/v4", webBaseUrl: "https://gitlab.example.com", token: "token", tokenType: "personal", headerName: "PRIVATE-TOKEN" } })),
GitLabClient,
buildGitLabTaskDescription: (item: any) => `${item.description?.trim() || "(no description)"}\n\nSource: ${item.webUrl ?? item.web_url}`,
buildGitLabTaskProvenance: ({ resourceType, item }: any) => ({ sourceIssue: { provider: "gitlab", repository: String(item.projectPath ?? item.project_id ?? "unknown"), externalIssueId: resourceType === "merge_request" ? `gitlab:mr:${item.project_id}:${item.id}` : String(item.id), issueNumber: item.iid, url: item.webUrl ?? item.web_url }, sourceMetadata: { provider: "gitlab", resourceType, iid: item.iid, webUrl: item.webUrl ?? item.web_url } }),
isGitLabAlreadyImported: (task: any, provenance: any) => task.description?.includes(provenance.sourceIssue.url) || task.sourceIssue?.externalIssueId === provenance.sourceIssue.externalIssueId,
};
});
import { runTaskImportFromGitLab } from "../commands/task.js";
describe("fn task import-gitlab", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
mocks.getSettings.mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" });
mocks.getGlobalSettings.mockResolvedValue({});
mocks.listTasks.mockResolvedValue([]);
mocks.createTask.mockImplementation(async (input) => ({ id: "FN-001", ...input }));
fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify([{ id: 1, iid: 2, project_id: 3, title: "GitLab issue", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/2", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }]), { status: 200 })));
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("imports GitLab project issues with gitlab provenance", async () => {
await runTaskImportFromGitLab("g/p", { resource: "project-issues", limit: 1 });
expect(fetchSpy.mock.calls[0][0]).toContain("/projects/g%2Fp/issues?");
expect(mocks.createTask).toHaveBeenCalledWith(expect.objectContaining({
sourceIssue: expect.objectContaining({ provider: "gitlab", issueNumber: 2 }),
source: expect.objectContaining({ sourceType: "gitlab_import", sourceMetadata: expect.objectContaining({ resourceType: "project_issue" }) }),
}));
});
it("skips duplicates by GitLab source URL", async () => {
mocks.listTasks.mockResolvedValue([{ id: "FN-OLD", description: "Source: https://gitlab.example.com/g/p/-/issues/2" }]);
await runTaskImportFromGitLab("g/p", { resource: "project-issues", limit: 1 });
expect(mocks.createTask).not.toHaveBeenCalled();
});
it("uses group issue and merge request endpoints", async () => {
await runTaskImportFromGitLab("g", { resource: "group-issues", limit: 1 });
expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/groups/g/issues?");
await runTaskImportFromGitLab("g/p", { resource: "merge-requests", limit: 1 });
expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/projects/g%2Fp/merge_requests?");
});
});