## 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>
419 lines
18 KiB
TypeScript
419 lines
18 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;
|
|
/*
|
|
FNXC:IssueImportAttachments 2026-07-15-14:10:
|
|
Issue-note text is auxiliary attachment input. Bound both requests and retained bodies so a hostile or unusually large issue cannot make import pagination unbounded.
|
|
*/
|
|
const MAX_NOTE_PAGES = 10;
|
|
const MAX_NOTE_BODIES = 500;
|
|
|
|
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:IssueImportAttachments 2026-07-15-13:40:
|
|
Import reads note bodies so screenshots posted in a comment ("here's the repro") reach the agent as attachments, not just those in the original description. Read-only: this does not post notes, and the "no comment side effects" rule above governs writes, not reads.
|
|
Returns bodies only — the caller needs image references, not authorship — and swallows nothing: the caller decides that a notes failure is non-fatal.
|
|
*/
|
|
async listNotes(resource: "issues" | "merge_requests", project: string | number, iid: number): Promise<string[]> {
|
|
const bodies: string[] = [];
|
|
for (let page = 1; page <= MAX_NOTE_PAGES && bodies.length < MAX_NOTE_BODIES; page++) {
|
|
const raw = await this.request<unknown>(`projects/${encodeGitLabPathId(project)}/${resource}/${iid}/notes?per_page=${PAGE_SIZE}&page=${page}`);
|
|
if (!Array.isArray(raw) || raw.length === 0) break;
|
|
for (const note of raw) {
|
|
const body = (note as { body?: unknown })?.body;
|
|
if (typeof body === "string") bodies.push(body);
|
|
if (bodies.length === MAX_NOTE_BODIES) break;
|
|
}
|
|
if (raw.length < PAGE_SIZE) break;
|
|
}
|
|
return bodies;
|
|
}
|
|
|
|
/*
|
|
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.
|
|
*/
|