Add GitLab tracking persistence, analytics, and reconciliation coverage across core, dashboard, and CLI paths. - Store GitLab tracking and closed-at metadata when importing issues and merge requests from CLI, extension tools, and dashboard routes. - Add GitLab source-issue analytics, Command Center GitLab signal surfaces, CSV export fields, and a closed-at backfill route. - Cover GitLab tracking storage, reconciliation, CLI imports, dashboard import UI, route behavior, and Command Center signals with focused tests. Files changed: .changeset/fn-7428-gitlab-import-tracking.md | 7 + .../__tests__/extension-gitlab-tracking.test.ts | 140 ++++++++++++ .../__tests__/task-command-gitlab-import.test.ts | 18 +- packages/cli/src/commands/task.ts | 1 + packages/cli/src/extension.ts | 2 +- .../src/__tests__/gitlab-issue-analytics.test.ts | 243 +++++++++++++++++++++ .../__tests__/gitlab-source-issue-storage.test.ts | 128 +++++++++++ .../store-gitlab-tracking-reconcile.test.ts | 125 +++++++++++ packages/core/src/gitlab-issue-analytics.ts | 227 +++++++++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/store.ts | 21 +- .../__tests__/GitHubImportModal.test.tsx | 46 ++++ .../components/__tests__/gitlabTracking.test.tsx | 43 ++++ .../components/command-center/CommandCenter.tsx | 5 + .../components/command-center/areas/GitlabArea.tsx | 126 +++++++++++ .../areas/__tests__/areas.gitlab-signals.test.tsx | 83 +++++++ .../gitlab-source-issue-reconciler.test.ts | 187 ++++++++++++++++ .../register-command-center-routes.test.ts | 52 +++++ .../__tests__/register-git-gitlab.backfill.test.ts | 116 ++++++++++ .../dashboard/src/__tests__/routes-gitlab.test.ts | 59 +++-- packages/dashboard/src/command-center-csv.ts | 21 ++ .../src/gitlab-source-issue-reconciler.ts | 104 +++++++++ packages/dashboard/src/gitlab.ts | 16 +- .../src/routes/register-command-center-routes.ts | 25 +++ packages/dashboard/src/routes/register-gitlab.ts | 29 +++ 25 files changed, 1800 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7428 Fusion-Task-Lineage: 7b0ebb5c-c6e7-42a9-a339-b2dd1d4e263a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
import type { Task, TaskGitLabTracking, TaskSourceIssue } from "@fusion/core";
|
|
import type { ResolvedGitlabAuth } from "./gitlab-auth.js";
|
|
|
|
export type GitLabResourceType = "project_issue" | "group_issue" | "merge_request";
|
|
|
|
export interface GitLabAuthor {
|
|
id?: number;
|
|
username?: string;
|
|
name?: string;
|
|
avatarUrl?: string;
|
|
webUrl?: string;
|
|
}
|
|
|
|
export interface GitLabProjectIdentity {
|
|
id?: number;
|
|
pathWithNamespace?: string;
|
|
webUrl?: string;
|
|
}
|
|
|
|
export interface GitLabIssue {
|
|
resourceKind: "project_issue" | "group_issue";
|
|
id?: number;
|
|
iid: number;
|
|
projectId?: number;
|
|
projectPath?: string;
|
|
groupId?: number | string;
|
|
groupPath?: string;
|
|
title: string;
|
|
description: string | null;
|
|
webUrl: string;
|
|
state: string;
|
|
author?: GitLabAuthor | null;
|
|
labels: string[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
closedAt?: string;
|
|
commentsCount?: number;
|
|
}
|
|
|
|
export interface GitLabMergeRequest {
|
|
resourceKind: "merge_request";
|
|
id?: number;
|
|
iid: number;
|
|
projectId?: number;
|
|
projectPath?: string;
|
|
title: string;
|
|
description: string | null;
|
|
webUrl: string;
|
|
state: string;
|
|
author?: GitLabAuthor | null;
|
|
labels: string[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
closedAt?: string;
|
|
mergedAt?: string;
|
|
commentsCount?: number;
|
|
sourceBranch?: string;
|
|
targetBranch?: string;
|
|
draft?: boolean;
|
|
}
|
|
|
|
export interface GitLabListOptions {
|
|
limit?: number;
|
|
labels?: string[];
|
|
state?: string;
|
|
}
|
|
|
|
export class GitLabApiError extends Error {
|
|
constructor(public readonly status: number, message: string) {
|
|
super(message);
|
|
this.name = "GitLabApiError";
|
|
}
|
|
}
|
|
|
|
const DEFAULT_LIMIT = 30;
|
|
const MAX_LIMIT = 100;
|
|
const PAGE_SIZE = 100;
|
|
|
|
function clampLimit(limit: unknown): number {
|
|
if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_LIMIT;
|
|
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit)));
|
|
}
|
|
|
|
export function encodeGitLabPathId(id: string | number): string {
|
|
return encodeURIComponent(String(id).trim());
|
|
}
|
|
|
|
function appendListParams(params: URLSearchParams, options: GitLabListOptions): void {
|
|
const state = typeof options.state === "string" && options.state.trim() ? options.state.trim() : "opened";
|
|
params.set("state", state);
|
|
if (options.labels && options.labels.length > 0) {
|
|
params.set("labels", options.labels.map((label) => label.trim()).filter(Boolean).join(","));
|
|
}
|
|
}
|
|
|
|
type GitLabRaw = Record<string, unknown>;
|
|
|
|
function asRecord(value: unknown): GitLabRaw {
|
|
return value && typeof value === "object" ? value as GitLabRaw : {};
|
|
}
|
|
|
|
function normalizeAuthor(author: unknown): GitLabAuthor | null {
|
|
const raw = asRecord(author);
|
|
if (Object.keys(raw).length === 0) return null;
|
|
return {
|
|
...(typeof raw.id === "number" ? { id: raw.id } : {}),
|
|
...(typeof raw.username === "string" ? { username: raw.username } : {}),
|
|
...(typeof raw.name === "string" ? { name: raw.name } : {}),
|
|
...(typeof raw.avatar_url === "string" ? { avatarUrl: raw.avatar_url } : {}),
|
|
...(typeof raw.web_url === "string" ? { webUrl: raw.web_url } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeLabels(labels: unknown): string[] {
|
|
return Array.isArray(labels) ? labels.filter((label): label is string => typeof label === "string") : [];
|
|
}
|
|
|
|
function readProjectPath(input: unknown): string | undefined {
|
|
const raw = asRecord(input);
|
|
if (typeof raw.project_path_with_namespace === "string") return raw.project_path_with_namespace;
|
|
const references = asRecord(raw.references);
|
|
if (typeof references.full === "string") {
|
|
const full = references.full;
|
|
const webUrl = typeof raw.web_url === "string" ? raw.web_url : "";
|
|
const marker = webUrl.includes("/-/merge_requests/") ? "!" : "#";
|
|
const idx = full.lastIndexOf(marker);
|
|
return idx > 0 ? full.slice(0, idx) : undefined;
|
|
}
|
|
if (typeof raw?.web_url === "string") {
|
|
const marker = raw.web_url.includes("/-/merge_requests/") ? "/-/merge_requests/" : "/-/issues/";
|
|
try {
|
|
const url = new URL(raw.web_url);
|
|
const idx = url.pathname.indexOf(marker);
|
|
return idx > 0 ? decodeURIComponent(url.pathname.slice(1, idx)) : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeIssue(input: unknown, resourceKind: "project_issue" | "group_issue", extras: Partial<GitLabIssue> = {}): GitLabIssue {
|
|
const raw = asRecord(input);
|
|
return {
|
|
resourceKind,
|
|
...(typeof raw.id === "number" ? { id: raw.id } : {}),
|
|
iid: Number(raw.iid),
|
|
...(typeof raw.project_id === "number" ? { projectId: raw.project_id } : {}),
|
|
...(readProjectPath(raw) ? { projectPath: readProjectPath(raw) } : {}),
|
|
title: String(raw.title ?? ""),
|
|
description: typeof raw.description === "string" ? raw.description : null,
|
|
webUrl: String(raw.web_url ?? ""),
|
|
state: String(raw.state ?? "unknown"),
|
|
author: normalizeAuthor(raw.author),
|
|
labels: normalizeLabels(raw.labels),
|
|
...(typeof raw.created_at === "string" ? { createdAt: raw.created_at } : {}),
|
|
...(typeof raw.updated_at === "string" ? { updatedAt: raw.updated_at } : {}),
|
|
...(typeof raw.closed_at === "string" ? { closedAt: raw.closed_at } : {}),
|
|
...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}),
|
|
...extras,
|
|
};
|
|
}
|
|
|
|
function normalizeMergeRequest(input: unknown, extras: Partial<GitLabMergeRequest> = {}): GitLabMergeRequest {
|
|
const raw = asRecord(input);
|
|
return {
|
|
resourceKind: "merge_request",
|
|
...(typeof raw.id === "number" ? { id: raw.id } : {}),
|
|
iid: Number(raw.iid),
|
|
...(typeof raw.project_id === "number" ? { projectId: raw.project_id } : {}),
|
|
...(readProjectPath(raw) ? { projectPath: readProjectPath(raw) } : {}),
|
|
title: String(raw.title ?? ""),
|
|
description: typeof raw.description === "string" ? raw.description : null,
|
|
webUrl: String(raw.web_url ?? ""),
|
|
state: String(raw.state ?? "unknown"),
|
|
author: normalizeAuthor(raw.author),
|
|
labels: normalizeLabels(raw.labels),
|
|
...(typeof raw.created_at === "string" ? { createdAt: raw.created_at } : {}),
|
|
...(typeof raw.updated_at === "string" ? { updatedAt: raw.updated_at } : {}),
|
|
...(typeof raw.closed_at === "string" ? { closedAt: raw.closed_at } : {}),
|
|
...(typeof raw.merged_at === "string" ? { mergedAt: raw.merged_at } : {}),
|
|
...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}),
|
|
...(typeof raw.source_branch === "string" ? { sourceBranch: raw.source_branch } : {}),
|
|
...(typeof raw.target_branch === "string" ? { targetBranch: raw.target_branch } : {}),
|
|
...(typeof raw.draft === "boolean" ? { draft: raw.draft } : {}),
|
|
...extras,
|
|
};
|
|
}
|
|
|
|
export class GitLabClient {
|
|
constructor(public readonly auth: ResolvedGitlabAuth, private readonly fetchImpl: typeof fetch = fetch) {}
|
|
|
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const url = `${this.auth.apiBaseUrl.replace(/\/+$/u, "")}/${path.replace(/^\/+/, "")}`;
|
|
const response = await this.fetchImpl(url, {
|
|
...init,
|
|
headers: {
|
|
Accept: "application/json",
|
|
[this.auth.headerName]: this.auth.token,
|
|
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new GitLabApiError(response.status, mapGitLabError(response.status));
|
|
}
|
|
if (response.status === 204) return undefined as T;
|
|
return await response.json() as T;
|
|
}
|
|
|
|
private async listPaginated<T>(path: string, options: GitLabListOptions, normalize: (raw: unknown) => T): Promise<T[]> {
|
|
const limit = clampLimit(options.limit);
|
|
const out: T[] = [];
|
|
let page = 1;
|
|
while (out.length < limit && page <= Math.ceil(limit / PAGE_SIZE) + 1) {
|
|
const params = new URLSearchParams();
|
|
params.set("per_page", String(Math.min(PAGE_SIZE, limit - out.length)));
|
|
params.set("page", String(page));
|
|
appendListParams(params, options);
|
|
const rows = await this.request<unknown[]>(`${path}?${params.toString()}`);
|
|
if (!Array.isArray(rows) || rows.length === 0) break;
|
|
out.push(...rows.map(normalize));
|
|
if (rows.length < Number(params.get("per_page"))) break;
|
|
page += 1;
|
|
}
|
|
return out.slice(0, limit);
|
|
}
|
|
|
|
listProjectIssues(project: string | number, options: GitLabListOptions = {}): Promise<GitLabIssue[]> {
|
|
return this.listPaginated(`projects/${encodeGitLabPathId(project)}/issues`, options, (raw) => normalizeIssue(raw, "project_issue"));
|
|
}
|
|
|
|
listGroupIssues(group: string | number, options: GitLabListOptions = {}): Promise<GitLabIssue[]> {
|
|
return this.listPaginated(`groups/${encodeGitLabPathId(group)}/issues`, options, (raw) => normalizeIssue(raw, "group_issue", {
|
|
groupPath: typeof group === "string" ? group : undefined,
|
|
groupId: typeof group === "number" ? group : undefined,
|
|
}));
|
|
}
|
|
|
|
listMergeRequests(project: string | number, options: GitLabListOptions = {}): Promise<GitLabMergeRequest[]> {
|
|
return this.listPaginated(`projects/${encodeGitLabPathId(project)}/merge_requests`, options, normalizeMergeRequest);
|
|
}
|
|
|
|
async getProjectIssue(project: string | number, iid: number): Promise<GitLabIssue> {
|
|
return normalizeIssue(await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}`), "project_issue");
|
|
}
|
|
|
|
async getMergeRequest(project: string | number, iid: number): Promise<GitLabMergeRequest> {
|
|
return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}`));
|
|
}
|
|
|
|
/*
|
|
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.
|
|
*/
|
|
async commentOnProjectIssue(project: string | number, iid: number, body: string): Promise<void> {
|
|
await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}/notes`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
|
|
async commentOnMergeRequest(project: string | number, iid: number, body: string): Promise<void> {
|
|
await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}/notes`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
|
|
async setProjectIssueState(project: string | number, iid: number, state: "opened" | "closed"): Promise<GitLabIssue> {
|
|
const params = new URLSearchParams({ state_event: state === "closed" ? "close" : "reopen" });
|
|
return normalizeIssue(await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}?${params.toString()}`, { method: "PUT" }), "project_issue");
|
|
}
|
|
|
|
async setMergeRequestState(project: string | number, iid: number, state: "opened" | "closed"): Promise<GitLabMergeRequest> {
|
|
const params = new URLSearchParams({ state_event: state === "closed" ? "close" : "reopen" });
|
|
return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}?${params.toString()}`, { method: "PUT" }));
|
|
}
|
|
}
|
|
|
|
export function mapGitLabError(status: number): string {
|
|
switch (status) {
|
|
case 401:
|
|
return "GitLab authentication failed. Check gitlabAuthToken and required read_api scope.";
|
|
case 403:
|
|
return "GitLab authorization failed. Check token scope and project/group access.";
|
|
case 404:
|
|
return "GitLab project, group, issue, or merge request was not found.";
|
|
case 429:
|
|
return "GitLab rate limit exceeded. Try again later.";
|
|
default:
|
|
return status >= 500 ? "GitLab is unavailable. Try again later." : `GitLab API request failed with status ${status}.`;
|
|
}
|
|
}
|
|
|
|
function projectIdentity(item: GitLabIssue | GitLabMergeRequest): string {
|
|
return item.projectPath ?? (item.projectId !== undefined ? String(item.projectId) : "unknown-project");
|
|
}
|
|
|
|
export function buildGitLabTaskProvenance(args: {
|
|
auth: Pick<ResolvedGitlabAuth, "apiBaseUrl" | "webBaseUrl">;
|
|
resourceType: GitLabResourceType;
|
|
item: GitLabIssue | GitLabMergeRequest;
|
|
projectInput?: string | number;
|
|
groupInput?: string | number;
|
|
}): { sourceIssue: TaskSourceIssue; gitlabTracking: TaskGitLabTracking; sourceMetadata: Record<string, unknown> } {
|
|
const { auth, resourceType, item } = args;
|
|
const groupPathFromInput = typeof args.groupInput === "string" ? args.groupInput : undefined;
|
|
const groupIdFromInput = typeof args.groupInput === "number" ? args.groupInput : undefined;
|
|
const repository = projectIdentity(item);
|
|
const externalIssueId = resourceType === "merge_request"
|
|
? `gitlab:mr:${item.projectId ?? repository}:${item.id ?? item.iid}`
|
|
: String(item.id ?? `${item.projectId ?? repository}:${item.iid}`);
|
|
const url = new URL(item.webUrl);
|
|
return {
|
|
sourceIssue: {
|
|
provider: "gitlab",
|
|
repository,
|
|
externalIssueId,
|
|
issueNumber: item.iid,
|
|
url: item.webUrl,
|
|
},
|
|
gitlabTracking: {
|
|
item: {
|
|
kind: resourceType,
|
|
url: item.webUrl,
|
|
instanceUrl: auth.webBaseUrl,
|
|
host: url.host,
|
|
iid: item.iid,
|
|
...(typeof item.id === "number" ? { id: item.id } : {}),
|
|
...(typeof item.projectId === "number" ? { projectId: item.projectId } : {}),
|
|
...(typeof item.projectPath === "string" ? { projectPath: item.projectPath } : {}),
|
|
...("groupId" in item && item.groupId !== undefined ? { groupId: item.groupId } : groupIdFromInput !== undefined ? { groupId: groupIdFromInput } : {}),
|
|
...("groupPath" in item && item.groupPath !== undefined ? { groupPath: item.groupPath } : groupPathFromInput !== undefined ? { groupPath: groupPathFromInput } : {}),
|
|
title: item.title,
|
|
state: item.state,
|
|
createdAt: item.createdAt ?? new Date().toISOString(),
|
|
linkedAt: new Date().toISOString(),
|
|
...(item.updatedAt ? { lastSyncedAt: item.updatedAt } : {}),
|
|
},
|
|
},
|
|
sourceMetadata: {
|
|
provider: "gitlab",
|
|
resourceType,
|
|
instanceUrl: auth.webBaseUrl,
|
|
apiBaseUrl: auth.apiBaseUrl,
|
|
projectId: item.projectId,
|
|
projectPath: item.projectPath,
|
|
groupId: "groupId" in item && item.groupId !== undefined ? item.groupId : groupIdFromInput,
|
|
groupPath: "groupPath" in item && item.groupPath !== undefined ? item.groupPath : groupPathFromInput,
|
|
projectInput: args.projectInput,
|
|
groupInput: args.groupInput,
|
|
iid: item.iid,
|
|
webUrl: item.webUrl,
|
|
...(resourceType === "merge_request" ? {
|
|
mergeRequestId: item.id,
|
|
mergeRequestIid: item.iid,
|
|
sourceBranch: (item as GitLabMergeRequest).sourceBranch,
|
|
targetBranch: (item as GitLabMergeRequest).targetBranch,
|
|
draft: (item as GitLabMergeRequest).draft,
|
|
} : {
|
|
issueId: item.id,
|
|
issueIid: item.iid,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function isGitLabAlreadyImported(task: Pick<Task, "description" | "sourceIssue" | "source">, provenance: { sourceIssue: TaskSourceIssue; sourceMetadata: Record<string, unknown> }): boolean {
|
|
const sourceUrl = provenance.sourceIssue.url;
|
|
if (sourceUrl && task.description?.includes(sourceUrl)) return true;
|
|
if (task.sourceIssue?.provider === "gitlab" && task.sourceIssue.externalIssueId === provenance.sourceIssue.externalIssueId) return true;
|
|
const metadata = task.source?.sourceMetadata;
|
|
if (task.source?.sourceType === "gitlab_import" && metadata && typeof metadata === "object") {
|
|
const meta = metadata as Record<string, unknown>;
|
|
return meta.resourceType === provenance.sourceMetadata.resourceType
|
|
&& meta.iid === provenance.sourceMetadata.iid
|
|
&& (meta.projectId === provenance.sourceMetadata.projectId || meta.projectPath === provenance.sourceMetadata.projectPath)
|
|
&& (meta.webUrl === sourceUrl || sourceUrl === undefined);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function buildGitLabTaskDescription(item: GitLabIssue | GitLabMergeRequest): string {
|
|
const body = item.description?.trim() || "(no description)";
|
|
return `${body}\n\nSource: ${item.webUrl}`;
|
|
}
|
|
|
|
/*
|
|
FNXC:GitLabImport 2026-07-02-00:00:
|
|
FN-7424 adds HTTP API-only GitLab imports for project issues, group issues, and merge requests. Keep this client token-based and provenance-focused: do not invoke `glab`, post comments, auto-close resources, add tracking UI, Command Center signals, research/search providers, or star prompts in this slice.
|
|
*/
|