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>
98 lines
5.0 KiB
TypeScript
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?");
|
|
});
|
|
});
|