FN-7425: add GitLab tracking metadata to tasks

Persist GitLab tracking metadata and surface it across task APIs and dashboard views.

- add task-store schema, migration, and update helpers for linked GitLab items and stale state
- expose validated GitLab tracking updates through task workflow routes and legacy task payloads
- render GitLab badges, detail-panel metadata, open/unlink actions, styling, i18n strings, and docs
- cover persistence, route validation, task card badges, and task detail interactions with tests
- add a patch changeset for the published Fusion package

Files changed:
 .changeset/fn-7425-gitlab-tracking.md              |   7 ++
 docs/cli-reference.md                              |   2 +-
 docs/dashboard-guide.md                            |   3 +
 packages/core/src/__tests__/db-migrate.test.ts     |  29 +++++
 .../src/__tests__/store-gitlab-tracking.test.ts    | 138 +++++++++++++++++++++
 packages/core/src/db.ts                            |  10 +-
 packages/core/src/gitlab-tracking.ts               |  10 ++
 packages/core/src/index.ts                         |   3 +-
 packages/core/src/store.ts                         | 135 +++++++++++++++++++-
 packages/core/src/types.ts                         |  49 ++++++++
 packages/dashboard/app/api/legacy.ts               |   3 +
 packages/dashboard/app/components/GitLabBadge.tsx  |  33 +++++
 packages/dashboard/app/components/TaskCard.tsx     |   7 +-
 .../dashboard/app/components/TaskCardBadge.tsx     |  15 ++-
 .../dashboard/app/components/TaskDetailModal.css   |  47 +++++--
 .../dashboard/app/components/TaskDetailModal.tsx   | 134 +++++++++++++++++++-
 .../app/components/__tests__/TaskCard.test.tsx     |  49 ++++++++
 .../TaskDetailModal.gitlab-tracking.test.tsx       | 121 ++++++++++++++++++
 .../__tests__/TaskDetailModal.test-helpers.ts      |   1 +
 packages/dashboard/app/styles.css                  |   9 ++
 .../src/__tests__/routes-tasks-ops.test.ts         | 136 ++++++++++++++++++++
 packages/dashboard/src/gitlab.ts                   |  24 +++-
 packages/dashboard/src/routes/register-gitlab.ts   |   1 +
 .../src/routes/register-task-workflow-routes.ts    | 106 +++++++++++++++-
 packages/i18n/locales/en/app.json                  |  31 +++++
 packages/i18n/src/resources.d.ts                   |  31 +++++
 26 files changed, 1106 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-7425

Fusion-Task-Lineage: 9b5e6005-7284-402e-995c-553be2275eff

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 10:40:37 -07:00
parent 865dec235b
commit b8e126eeaf
26 changed files with 1106 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Display linked GitLab tracking metadata and stale badges on tasks.
category: feature
dev: Persists GitLab tracking metadata separately from GitHub tracking fields.

View File

@@ -649,7 +649,7 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo
`fn task import` creates Fusion tasks from GitHub issues. If project or global GitHub tracking defaults are enabled, imported issue tasks are marked as tracked and the tracking hook links the source issue itself instead of opening a duplicate Fusion tracking issue.
`fn task import-gitlab` creates Fusion tasks from GitLab project issues, group issues, or project merge requests using the configured GitLab instance/API URL and access token (`read_api` or `api` scope). It uses the GitLab HTTP API only (no `glab` dependency), supports GitLab.com and self-managed instances, stores `gitlab_import` provenance, and skips duplicates by source URL/provenance.
`fn task import-gitlab` creates Fusion tasks from GitLab project issues, group issues, or project merge requests using the configured GitLab instance/API URL and access token (`read_api` or `api` scope). It uses the GitLab HTTP API only (no `glab` dependency), supports GitLab.com and self-managed instances, stores `gitlab_import` provenance plus `gitlabTracking` task metadata for dashboard badges/details, and skips duplicates by source URL/provenance. GitLab comment posting and remote auto-close are not part of this command yet.
```bash
fn pr create FN-001

View File

@@ -138,6 +138,7 @@ Features:
- Inline quick entry creation
- The quick-entry GitHub icon is a per-task tracking override: leave it untouched to use the project default, turn it on to opt the next task into tracking when the default is off, or turn it off to opt the next task out when the default is on.
- PR/issue badges with live updates
- GitLab tracking badges on task cards for linked GitLab project issues, group issues, and merge requests; stale GitLab metadata uses a warning-colored badge while GitHub badges remain unchanged.
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
- Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context.
@@ -1072,6 +1073,8 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- After delete confirmations are complete, Task Detail closes immediately while the delete request finishes in the background; success and error outcomes still appear as toasts.
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
- Tasks linked to GitLab imports show a separate **GitLab tracking** section for GitLab.com and self-managed project issues, group issues, and merge requests. The section provides **Open in GitLab** and local **Unlink GitLab item** actions only; comment posting, remote close/delete, and auto-close behavior are reserved for later GitLab parity work.
- GitLab stale state means Fusion is displaying the last persisted GitLab metadata after a sync/import refresh could not confirm a newer state; no GitLab token or secret is stored on the task.
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.

View File

@@ -687,6 +687,35 @@ describe("schema migration", () => {
db.close();
});
it("adds tasks.gitlabTracking when migrating from schema version 134", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
githubTracking TEXT
)
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '134')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '{"enabled":true}')`);
db.init();
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("gitlabTracking");
const row = db.prepare("SELECT githubTracking, gitlabTracking FROM tasks WHERE id = 'FN-legacy'").get() as { githubTracking: string; gitlabTracking: string | null };
expect(JSON.parse(row.githubTracking).enabled).toBe(true);
expect(row.gitlabTracking).toBeNull();
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
db.close();
});
it("adds deletedAt column + index when migrating from schema version 86", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { TaskGitLabTrackedItem } from "../types.js";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-gitlab-tracking-test-"));
}
const projectIssue: TaskGitLabTrackedItem = {
kind: "project_issue",
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
id: 1001,
projectId: 7,
projectPath: "acme/app",
title: "Project issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
linkedAt: "2026-07-02T00:00:01.000Z",
lastSyncedAt: "2026-07-02T00:00:02.000Z",
};
const staleGroupIssue: TaskGitLabTrackedItem = {
kind: "group_issue",
url: "https://git.example.test/groups/platform/-/issues/9",
instanceUrl: "https://git.example.test",
host: "git.example.test",
iid: 9,
groupPath: "platform",
title: "Group issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
staleAt: "2026-07-02T01:00:00.000Z",
staleReason: "GitLab sync failed",
};
const mergeRequest: TaskGitLabTrackedItem = {
kind: "merge_request",
url: "https://gitlab.example.org/acme/app/-/merge_requests/5",
instanceUrl: "https://gitlab.example.org",
host: "gitlab.example.org",
iid: 5,
projectPath: "acme/app",
title: "Merge request",
state: "merged",
createdAt: "2026-07-02T00:00:00.000Z",
};
describe("TaskStore gitlab tracking", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("persists gitlabTracking through create, update, detail, slim, search, and modified-since paths", async () => {
const task = await store.createTask({ description: "Track GitLab", gitlabTracking: { item: projectIssue } });
expect((await store.getTask(task.id)).gitlabTracking?.item).toEqual(projectIssue);
await store.updateTask(task.id, { gitlabTracking: { item: staleGroupIssue } });
expect((await store.getTask(task.id)).gitlabTracking?.item).toEqual(staleGroupIssue);
const slim = await store.listTasks({ slim: true });
expect(slim.find((entry) => entry.id === task.id)?.gitlabTracking?.item).toEqual(staleGroupIssue);
expect((await store.searchTasks("Track GitLab", { slim: true })).find((entry) => entry.id === task.id)?.gitlabTracking?.item).toEqual(staleGroupIssue);
expect((await store.listTasksModifiedSince("1970-01-01T00:00:00.000Z")).tasks.find((entry) => entry.id === task.id)?.gitlabTracking?.item).toEqual(staleGroupIssue);
});
it("links, unlinks, and clears gitlabTracking without touching github/source metadata", async () => {
const task = await store.createTask({
description: "Coexist",
sourceIssue: { provider: "github", repository: "octo/repo", externalIssueId: "1", issueNumber: 1, url: "https://github.com/octo/repo/issues/1" },
githubTracking: { enabled: true, repoOverride: "octo/repo" },
});
await store.linkGitLabItem(task.id, mergeRequest);
let updated = await store.getTask(task.id);
expect(updated.gitlabTracking?.item).toEqual(mergeRequest);
expect(updated.githubTracking).toEqual({ enabled: true, repoOverride: "octo/repo" });
expect(updated.sourceIssue?.provider).toBe("github");
await store.unlinkGitLabItem(task.id);
updated = await store.getTask(task.id);
expect(updated.gitlabTracking?.item).toBeUndefined();
expect(updated.gitlabTracking?.unlinkedAt).toBeTruthy();
expect(updated.githubTracking?.repoOverride).toBe("octo/repo");
await store.updateTask(task.id, { gitlabTracking: null });
updated = await store.getTask(task.id);
expect(updated.gitlabTracking).toBeUndefined();
expect(updated.githubTracking?.repoOverride).toBe("octo/repo");
});
it("round-trips gitlabTracking across disk restart and archive restore", async () => {
const diskRoot = makeTmpDir();
const diskGlobal = makeTmpDir();
try {
const first = new TaskStore(diskRoot, diskGlobal);
await first.init();
const created = await first.createTask({ description: "Restart GitLab" });
await first.updateGitLabTracking(created.id, { item: projectIssue });
first.close();
const second = new TaskStore(diskRoot, diskGlobal);
await second.init();
const reloaded = (await second.listTasks()).find((entry) => entry.description === "Restart GitLab");
expect(reloaded?.gitlabTracking?.item).toEqual(projectIssue);
await second.moveTask(reloaded!.id, "todo");
await second.moveTask(reloaded!.id, "in-progress");
await second.moveTask(reloaded!.id, "done");
await second.archiveTask(reloaded!.id, false);
const restored = await second.unarchiveTask(reloaded!.id);
expect(restored.gitlabTracking?.item).toEqual(projectIssue);
second.close();
} finally {
await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(diskGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
});

View File

@@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 134;
const SCHEMA_VERSION = 135;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -339,6 +339,8 @@ CREATE TABLE IF NOT EXISTS tasks (
prInfos TEXT,
issueInfo TEXT,
githubTracking TEXT,
-- FNXC:GitLabTracking 2026-07-02-00:00: GitLab item links are a nullable JSON column so project/group issue and merge-request metadata from GitLab.com or self-managed instances round-trip without altering GitHub tracking/source-issue columns.
gitlabTracking TEXT,
sourceIssueProvider TEXT,
sourceIssueRepository TEXT,
sourceIssueExternalIssueId TEXT,
@@ -5496,6 +5498,12 @@ export class Database {
});
}
if (version < 135) {
this.applyMigration(135, () => {
this.addColumnIfMissing("tasks", "gitlabTracking", "TEXT");
});
}
}
/**

View File

@@ -0,0 +1,10 @@
import type { TaskGitLabTrackedItem } from "./types.js";
export function formatGitLabTrackedItemRef(item: Pick<TaskGitLabTrackedItem, "kind" | "iid" | "host">): string {
const marker = item.kind === "merge_request" ? "!" : "#";
return `${item.host} ${item.kind} ${marker}${item.iid}`;
}
export function isGitLabTrackingStale(item: Pick<TaskGitLabTrackedItem, "staleAt" | "staleReason"> | undefined): boolean {
return Boolean(item?.staleAt || item?.staleReason);
}

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export {
resolveEntryPointBranchAssignment,
@@ -30,6 +30,7 @@ export { resolvePlanApprovalRequired } from "./plan-approval.js";
export type { PlanApprovalMode } from "./plan-approval.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export { formatGitLabTrackedItemRef, isGitLabTrackingStale } from "./gitlab-tracking.js";
export * from "./frontend-ux-policy.js";
export * from "./file-scope-classification.js";
export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js";

View File

@@ -308,6 +308,7 @@ interface TaskRow {
prInfos: string | null;
issueInfo: string | null;
githubTracking: string | null;
gitlabTracking: string | null;
sourceIssueProvider: string | null;
sourceIssueRepository: string | null;
sourceIssueExternalIssueId: string | null;
@@ -470,6 +471,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("prInfos", (task) => toJson(task.prInfos || [])),
defineTaskColumn("issueInfo", (task) => toJsonNullable(task.issueInfo)),
defineTaskColumn("githubTracking", (task) => toJsonNullable(task.githubTracking)),
defineTaskColumn("gitlabTracking", (task) => toJsonNullable(task.gitlabTracking)),
defineTaskColumn("sourceIssueProvider", (task) => task.sourceIssue?.provider ?? null),
defineTaskColumn("sourceIssueRepository", (task) => task.sourceIssue?.repository ?? null),
defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null),
@@ -2189,6 +2191,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
})(),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
gitlabTracking: fromJson<import("./types.js").TaskGitLabTracking>(row.gitlabTracking) ?? undefined,
sourceIssue: (() => {
if (
row.sourceIssueProvider === null
@@ -2301,6 +2304,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
prInfos: slim ? undefined : entry.prInfos,
issueInfo: slim ? undefined : entry.issueInfo,
githubTracking: entry.githubTracking,
gitlabTracking: entry.gitlabTracking,
sourceIssue: slim ? undefined : entry.sourceIssue,
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
@@ -2436,6 +2440,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
prInfos: task.prInfos,
issueInfo: task.issueInfo,
githubTracking: task.githubTracking,
gitlabTracking: task.gitlabTracking,
sourceIssue: task.sourceIssue,
attachments: task.attachments,
comments: task.comments,
@@ -2662,7 +2667,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "gitlabTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "workflowTransitionNotification",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -2758,7 +2763,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "customFields", "attachments", "steeringComments",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "gitlabTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "workflowTransitionNotification",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -4983,6 +4988,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
tokenUsage: input.tokenUsage,
sourceIssue: input.sourceIssue,
githubTracking: input.githubTracking,
gitlabTracking: input.gitlabTracking,
sourceType: input.source?.sourceType ?? "unknown",
sourceAgentId: input.source?.sourceAgentId,
sourceRunId: input.source?.sourceRunId,
@@ -8294,7 +8300,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit<import("./types.js").TaskGitLabTracking, "item"> & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext));
@@ -9227,6 +9233,40 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
task.githubTracking = nextTracking;
}
if (updates.gitlabTracking === null) {
task.gitlabTracking = undefined;
} else if (updates.gitlabTracking !== undefined) {
const previousTracking = task.gitlabTracking;
const previousItem = previousTracking?.item;
const { item: gitlabItemPatch, ...gitlabTrackingPatch } = updates.gitlabTracking;
const nextTracking: import("./types.js").TaskGitLabTracking = {
...(previousTracking ?? {}),
...gitlabTrackingPatch,
};
if (gitlabItemPatch === null) {
if (previousItem) {
task.log.push({
timestamp: new Date().toISOString(),
action: "GitLab item unlinked",
outcome: `${previousItem.host} ${previousItem.kind} !${previousItem.iid}`,
...(runContext ? { runContext } : {}),
});
}
nextTracking.item = undefined;
nextTracking.unlinkedAt = new Date().toISOString();
} else if (gitlabItemPatch !== undefined) {
nextTracking.item = gitlabItemPatch;
task.log.push({
timestamp: new Date().toISOString(),
action: "GitLab item linked",
outcome: `${gitlabItemPatch.host} ${gitlabItemPatch.kind} !${gitlabItemPatch.iid}`,
...(runContext ? { runContext } : {}),
});
}
task.gitlabTracking = nextTracking;
}
if (updates.tokenUsage === null) {
task.tokenUsage = undefined;
} else if (updates.tokenUsage !== undefined) {
@@ -14107,6 +14147,94 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
});
}
async updateGitLabTracking(
id: string,
tracking: import("./types.js").TaskGitLabTracking | null,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const nextTracking = tracking ?? undefined;
const previousTracking = task.gitlabTracking;
if (JSON.stringify(previousTracking ?? null) === JSON.stringify(nextTracking ?? null)) {
return task;
}
task.gitlabTracking = nextTracking;
task.log.push({
timestamp: new Date().toISOString(),
action: nextTracking?.item ? "GitLab item linked" : "GitLab tracking cleared",
outcome: nextTracking?.item ? `${nextTracking.item.host} ${nextTracking.item.kind} !${nextTracking.item.iid}` : undefined,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async linkGitLabItem(
id: string,
item: import("./types.js").TaskGitLabTrackedItem,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previous = task.gitlabTracking ?? {};
const nextTracking: import("./types.js").TaskGitLabTracking = { ...previous, item };
if (JSON.stringify(previous) === JSON.stringify(nextTracking)) {
return task;
}
task.gitlabTracking = nextTracking;
task.log.push({
timestamp: new Date().toISOString(),
action: "GitLab item linked",
outcome: `${item.host} ${item.kind} !${item.iid}`,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async unlinkGitLabItem(id: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previous = task.gitlabTracking;
const previousItem = previous?.item;
if (!previousItem || !previous) {
return task;
}
task.gitlabTracking = {
...previous,
item: undefined,
unlinkedAt: new Date().toISOString(),
};
task.log.push({
timestamp: new Date().toISOString(),
action: "GitLab item unlinked",
outcome: `${previousItem.host} ${previousItem.kind} !${previousItem.iid}`,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Read historical agent log entries for a task from JSONL storage.
* Returns entries in chronological order (oldest first).
@@ -14743,6 +14871,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
review: entry.review,
issueInfo: entry.issueInfo,
githubTracking: entry.githubTracking,
gitlabTracking: entry.gitlabTracking,
sourceIssue: entry.sourceIssue,
attachments: entry.attachments,
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],

View File

@@ -1007,6 +1007,49 @@ export interface TaskGithubTrackedIssue {
export type GithubIssueAction = "close" | "delete" | "leave" | "auto";
export type GitLabTrackedItemKind = "project_issue" | "group_issue" | "merge_request";
/*
FNXC:GitLabTracking 2026-07-02-00:00:
GitLab tracking is a first-class task contract instead of overloading GitHub tracking because GitLab items can come from GitLab.com or self-managed instances and may be project issues, group issues, or merge requests. Store only public metadata and stale/link timestamps; never persist GitLab tokens here.
*/
export interface TaskGitLabTrackedItem {
/** GitLab work item kind imported or linked to this task. */
kind: GitLabTrackedItemKind;
/** Canonical browser URL for GitLab.com or a self-managed GitLab instance. */
url: string;
/** GitLab web instance/base URL, for example https://gitlab.com or a self-managed host. */
instanceUrl: string;
/** Parsed host for compact display/dedup diagnostics. */
host: string;
/** GitLab IID visible inside a project or group namespace. */
iid: number;
/** Optional global GitLab database id when import APIs supplied it. */
id?: number;
/** Project numeric id when the item belongs to a concrete project. */
projectId?: number;
/** Project path with namespace, when available from import or URL parsing. */
projectPath?: string;
/** Group id/path for group-issue searches where GitLab returns a group-scoped source. */
groupId?: number | string;
groupPath?: string;
/** Optional display title and live state snapshot; these are staleable metadata, not auth state. */
title?: string;
state?: string;
createdAt: string;
linkedAt?: string;
lastSyncedAt?: string;
staleAt?: string;
staleReason?: string;
}
export interface TaskGitLabTracking {
/** Per-task linked GitLab metadata. Separate from GitHub tracking because GitLab supports GitLab.com plus self-managed project/group/MR URLs without GitHub issue semantics. */
item?: TaskGitLabTrackedItem;
/** ISO-8601 of the most recent manual unlink, retained for audit. */
unlinkedAt?: string;
}
export interface TaskGithubTracking {
/** Per-task enabled override. When undefined, project/global default applies. */
enabled?: boolean;
@@ -2230,6 +2273,8 @@ export interface Task {
source?: TaskSource;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Linked GitLab tracking metadata for GitLab.com and self-managed GitLab items. */
gitlabTracking?: TaskGitLabTracking;
log: TaskLogEntry[];
/** Pre-aggregated sum of `[timing] … in <N>ms` log durations, in milliseconds.
* Computed server-side so slim board listings can render the card timer
@@ -2592,6 +2637,8 @@ export interface TaskCreateInput {
autoMerge?: boolean;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Linked GitLab tracking metadata for GitLab.com and self-managed GitLab items. */
gitlabTracking?: TaskGitLabTracking;
/** Optional persisted aggregate token usage snapshot for task creation/import paths. */
tokenUsage?: TaskTokenUsage;
/** Provenance metadata for task creation. */
@@ -4804,6 +4851,8 @@ export interface ArchivedTaskEntry {
prInfos?: PrInfo[];
issueInfo?: IssueInfo;
githubTracking?: TaskGithubTracking;
/** Linked GitLab tracking metadata for GitLab.com and self-managed GitLab items. */
gitlabTracking?: TaskGitLabTracking;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Attachment metadata (filenames, mime types, etc.) without file content */

View File

@@ -68,6 +68,8 @@ import type {
ResearchRunStatus,
TaskPriority,
TaskSourceIssue,
TaskGitLabTracking,
TaskGitLabTrackedItem,
PrConflictDiagnostics,
PrInfo,
ManagedDockerNodeInput,
@@ -535,6 +537,7 @@ export function updateTask(
repoOverride?: string | null;
issue?: null;
} | null;
gitlabTracking?: (Omit<TaskGitLabTracking, "item"> & { item?: TaskGitLabTrackedItem | null }) | null;
dismissNearDuplicate?: boolean;
},
projectId?: string,

View File

@@ -0,0 +1,33 @@
import { GitBranch, AlertTriangle } from "lucide-react";
import type { TaskGitLabTrackedItem } from "@fusion/core";
export function formatGitLabBadgeKind(item: Pick<TaskGitLabTrackedItem, "kind">): string {
if (item.kind === "merge_request") return "MR";
if (item.kind === "group_issue") return "Group issue";
return "Issue";
}
export function formatGitLabBadgeMarker(item: Pick<TaskGitLabTrackedItem, "kind" | "iid">): string {
return `${item.kind === "merge_request" ? "!" : "#"}${item.iid}`;
}
export function GitLabBadge({ item }: { item?: TaskGitLabTrackedItem }) {
if (!item) return null;
const stale = Boolean(item.staleAt || item.staleReason);
const title = `GitLab ${formatGitLabBadgeKind(item)} ${formatGitLabBadgeMarker(item)}${item.title ? `: ${item.title}` : ""}${stale ? ` — stale${item.staleReason ? `: ${item.staleReason}` : ""}` : ""}`;
return (
<a
className={`card-github-badge card-gitlab-badge ${stale ? "card-gitlab-badge--stale" : "card-github-badge--open"}`}
title={title}
href={item.url}
target="_blank"
rel="noopener noreferrer"
aria-label={title}
data-testid="card-gitlab-badge"
>
{stale ? <AlertTriangle size={10} aria-hidden="true" /> : <GitBranch size={10} aria-hidden="true" />}
<span>{formatGitLabBadgeMarker(item)}</span>
</a>
);
}

View File

@@ -15,6 +15,7 @@ import {
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, rebuildTaskSpec, refreshPrStatus, type WorkflowFieldDefinition } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { GitLabBadge } from "./GitLabBadge";
import { PrCreateModal } from "./PrCreateModal";
import { ProviderIcon } from "./ProviderIcon";
import { PluginSlot } from "./PluginSlot";
@@ -713,7 +714,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
}) &&
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) &&
// FNXC:GitHubTracking 2026-07-01-00:00: Context-menu tracking actions depend on githubTracking.enabled, so memoized cards must repaint when a PATCH enables tracking and remove the now-ineligible menu item.
JSON.stringify(previousTask.githubTracking ?? null) === JSON.stringify(nextTask.githubTracking ?? null)
JSON.stringify(previousTask.githubTracking ?? null) === JSON.stringify(nextTask.githubTracking ?? null) &&
JSON.stringify(previousTask.gitlabTracking ?? null) === JSON.stringify(nextTask.gitlabTracking ?? null)
);
}
@@ -2489,6 +2491,9 @@ function TaskCardComponent({
) : null}
</>
)}
{task.gitlabTracking?.item && (
<GitLabBadge item={task.gitlabTracking.item} />
)}
{prNode && (
prNode.state === "failed" ? (
<button

View File

@@ -1,7 +1,8 @@
import { memo, useEffect } from "react";
import type { IssueInfo, PrInfo } from "@fusion/core";
import type { IssueInfo, PrInfo, TaskGitLabTracking } from "@fusion/core";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { GitHubBadge } from "./GitHubBadge";
import { GitLabBadge } from "./GitLabBadge";
export function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
liveValue: T | null | undefined,
@@ -28,12 +29,13 @@ interface TaskCardBadgeProps {
taskId: string;
prInfo?: PrInfo;
issueInfo?: IssueInfo;
gitlabTracking?: TaskGitLabTracking;
updatedAt: string;
isInViewport: boolean;
projectId?: string;
}
function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInViewport, projectId }: TaskCardBadgeProps) {
function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, gitlabTracking, updatedAt, isInViewport, projectId }: TaskCardBadgeProps) {
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
const hasGitHubBadge = Boolean(prInfo || issueInfo);
@@ -63,11 +65,16 @@ function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInView
issueInfo?.lastCheckedAt ?? updatedAt,
);
if (!livePrInfo && !liveIssueInfo) {
if (!livePrInfo && !liveIssueInfo && !gitlabTracking?.item) {
return null;
}
return <GitHubBadge prInfo={livePrInfo} issueInfo={liveIssueInfo} />;
return (
<>
<GitHubBadge prInfo={livePrInfo} issueInfo={liveIssueInfo} />
<GitLabBadge item={gitlabTracking?.item} />
</>
);
}
export const TaskCardBadge = memo(TaskCardBadgeComponent);

View File

@@ -696,32 +696,42 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
border-top: 1px solid var(--border);
}
.detail-github-tracking-section {
.detail-github-tracking-section,
.detail-gitlab-tracking-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
padding: var(--space-xs) var(--space-md);
}
.detail-github-tracking-section .detail-source-header {
.detail-source-provider-badge--stale {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);
}
.detail-github-tracking-section .detail-source-header,
.detail-gitlab-tracking-section .detail-source-header {
flex-wrap: nowrap;
align-items: center;
min-width: 0;
}
.detail-github-tracking-section .detail-source-summary {
.detail-github-tracking-section .detail-source-summary,
.detail-gitlab-tracking-section .detail-source-summary {
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
}
.detail-github-tracking-section .detail-source-empty {
.detail-github-tracking-section .detail-source-empty,
.detail-gitlab-tracking-section .detail-source-empty {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-github-tracking-section .detail-source-toggle {
.detail-github-tracking-section .detail-source-toggle,
.detail-gitlab-tracking-section .detail-source-toggle {
flex: 0 0 auto;
}
@@ -741,13 +751,15 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
color: var(--text-muted);
}
.detail-github-tracking-content {
.detail-github-tracking-content,
.detail-gitlab-tracking-content {
margin-top: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px solid var(--border);
}
.detail-github-tracking-grid {
.detail-github-tracking-grid,
.detail-gitlab-tracking-grid {
margin-top: 0;
padding-top: 0;
}
@@ -765,7 +777,17 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
color: var(--text-muted);
}
.detail-github-tracking-controls {
.detail-gitlab-item-state {
font-weight: 600;
text-transform: lowercase;
}
.detail-gitlab-item-state--stale {
color: var(--color-warning);
}
.detail-github-tracking-controls,
.detail-gitlab-tracking-controls {
margin-top: var(--space-sm);
display: flex;
flex-direction: column;
@@ -2640,12 +2662,14 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
min-width: 0;
}
.detail-github-tracking-section .detail-source-header {
.detail-github-tracking-section .detail-source-header,
.detail-gitlab-tracking-section .detail-source-header {
flex-wrap: nowrap;
min-width: 0;
}
.detail-github-tracking-section .detail-source-summary {
.detail-github-tracking-section .detail-source-summary,
.detail-gitlab-tracking-section .detail-source-summary {
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
@@ -2663,7 +2687,8 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
margin-left: auto;
}
.detail-github-tracking-section .detail-source-header .detail-source-toggle {
.detail-github-tracking-section .detail-source-header .detail-source-toggle,
.detail-gitlab-tracking-section .detail-source-header .detail-source-toggle {
margin-left: 0;
}

View File

@@ -11,7 +11,7 @@ import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction, TaskGitLabTrackedItem } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
REPO_OVERRIDE_RE,
@@ -117,6 +117,16 @@ const markdownLinkifyComponents: Components = {
code: createMermaidCodeComponent("task-detail-mermaid-diagram", markdownLinkifyCodeComponent),
};
function formatGitLabItemKind(item: Pick<TaskGitLabTrackedItem, "kind">, t?: TFunction): string {
if (item.kind === "merge_request") return t ? t("taskDetail.gitlabTracking.kindMergeRequest", "Merge request") : "Merge request";
if (item.kind === "group_issue") return t ? t("taskDetail.gitlabTracking.kindGroupIssue", "Group issue") : "Group issue";
return t ? t("taskDetail.gitlabTracking.kindProjectIssue", "Project issue") : "Project issue";
}
function formatGitLabItemMarker(item: Pick<TaskGitLabTrackedItem, "kind" | "iid">): string {
return `${item.kind === "merge_request" ? "!" : "#"}${item.iid}`;
}
function hasUsableTrackingTitle(task: { title?: string | null; description?: string | null }): boolean {
if ((task.title ?? "").trim().length > 0) {
return true;
@@ -638,6 +648,7 @@ export function TaskDetailContent({
prompt: fullDetail.prompt,
log: fullDetail.log,
githubTracking: task.githubTracking ?? fullDetail.githubTracking,
gitlabTracking: task.gitlabTracking ?? fullDetail.gitlabTracking,
assignedAgentId: task.assignedAgentId === undefined ? fullDetail.assignedAgentId : task.assignedAgentId,
checkedOutBy: task.checkedOutBy === undefined ? fullDetail.checkedOutBy : task.checkedOutBy,
status: task.status === undefined ? fullDetail.status : task.status,
@@ -938,6 +949,7 @@ export function TaskDetailContent({
const [activityViewMenuPosition, setActivityViewMenuPosition] = useState<ActivityViewMenuPosition | null>(null);
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries");
const [gitlabTrackingExpanded, setGitlabTrackingExpanded] = useState(false);
const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false);
const [githubRepoOverrideDraft, setGithubRepoOverrideDraft] = useState(task.githubTracking?.repoOverride ?? "");
const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState<boolean | null>(null);
@@ -1342,6 +1354,7 @@ export function TaskDetailContent({
const canEditGithubTracking = GITHUB_TRACKING_EDITABLE_COLUMNS.has(task.column) && !isSaving;
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true);
const githubTrackedIssue = workingTask.githubTracking?.issue;
const gitlabTrackedItem = workingTask.gitlabTracking?.item;
const githubTrackingDetailPending = detailLoading && typeof task.githubTracking === "undefined";
const canCreateTrackingIssue = hasUsableTrackingTitle(task);
const showInlineGithubTrackingEnableButton =
@@ -1349,7 +1362,7 @@ export function TaskDetailContent({
&& !githubTrackedIssue
&& !githubTrackingDetailPending
&& (!githubTrackingEnabled || (isSavingGithubTracking && workingTask.githubTracking?.enabled !== true));
const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue);
const showGithubTrackingSection = (canEditGithubTracking && !gitlabTrackedItem) || githubTrackingEnabled || Boolean(githubTrackedIssue);
const retrySummary = task.retrySummary;
const retryRows = [
{ key: "stuckKill", label: t("taskDetail.retries.stuckKill", "Stuck kills"), title: t("taskDetail.retries.stuckKillTitle", "Stuck-task detector forced agent kill retries"), value: retrySummary?.stuckKill ?? 0 },
@@ -1363,6 +1376,13 @@ export function TaskDetailContent({
{ key: "reviewerContext", label: t("taskDetail.retries.reviewerContext", "Reviewer context retries"), title: t("taskDetail.retries.reviewerContextTitle", "FN-4082 compact reviewer retry"), value: retrySummary?.reviewerContext ?? 0 },
{ key: "reviewerFallback", label: t("taskDetail.retries.reviewerFallback", "Reviewer fallback retries"), title: t("taskDetail.retries.reviewerFallbackTitle", "FN-4092 fallback-model retry"), value: retrySummary?.reviewerFallback ?? 0 },
].filter((row) => row.value > 0);
const gitlabTrackingStale = Boolean(gitlabTrackedItem?.staleAt || gitlabTrackedItem?.staleReason);
const gitlabTrackingStatus = gitlabTrackingStale
? t("taskDetail.gitlabTracking.statusStale", "Stale")
: gitlabTrackedItem
? t("taskDetail.gitlabTracking.statusLinked", "Linked")
: t("taskDetail.gitlabTracking.statusUnlinked", "Unlinked");
const showGitLabTrackingSection = Boolean(gitlabTrackedItem || workingTask.gitlabTracking?.unlinkedAt);
const githubTrackingStatus = githubTrackingDetailPending
? t("taskDetail.githubTracking.statusLoading", "Loading")
: githubTrackedIssue
@@ -1887,6 +1907,31 @@ export function TaskDetailContent({
}
}, [addToast, canEdit, confirm, githubTrackedIssue, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const handleUnlinkGitLabItem = useCallback(async () => {
if (!canEdit || !gitlabTrackedItem || isSavingGithubTracking) return;
const confirmed = await confirm({
title: t("taskDetail.gitlabTracking.unlinkTitle", "Unlink GitLab item?"),
message: t("taskDetail.gitlabTracking.unlinkMessage", "This removes the local GitLab tracking link. The GitLab issue or merge request itself will not be modified."),
confirmLabel: t("taskDetail.gitlabTracking.unlinkConfirm", "Unlink"),
danger: true,
});
if (!confirmed) return;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, { gitlabTracking: { item: null } }, projectId);
setFullDetail((prev) => prev
? ({ ...prev, ...updatedTask, gitlabTracking: updatedTask.gitlabTracking } as TaskDetail)
: (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
addToast(t("taskDetail.gitlabTracking.itemUnlinked", "GitLab item unlinked"), "success");
} catch (err) {
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, canEdit, confirm, gitlabTrackedItem, isSavingGithubTracking, onTaskUpdated, projectId, task.id, t]);
const {
entries: agentLogEntries,
loading: agentLogLoading,
@@ -3998,6 +4043,12 @@ export function TaskDetailContent({
<span>{t("taskDetail.sourceIssue.githubBadge", "GitHub")}</span>
</span>
)}
{task.sourceIssue.provider.toLowerCase() === "gitlab" && (
<span className="detail-source-provider-badge" aria-label={t("taskDetail.sourceIssue.gitlabAriaLabel", "GitLab source item")}>
<GitBranch aria-hidden="true" />
<span>{t("taskDetail.sourceIssue.gitlabBadge", "GitLab")}</span>
</span>
)}
{task.sourceIssue.url ? (
<a
className="detail-source-link detail-source-link--summary detail-source-number"
@@ -4240,6 +4291,85 @@ export function TaskDetailContent({
<div className="detail-prompt">{t("taskDetail.spec.noPrompt", "(no prompt)")}</div>
)}
</div>
{showGitLabTrackingSection && (
<div className="detail-section detail-gitlab-tracking-section" data-testid="detail-gitlab-tracking-section">
<div className="detail-source-header">
<div className="detail-source-summary">
<span className="detail-source-label">{t("taskDetail.gitlabTracking.label", "GitLab tracking")}</span>
<span className={`detail-source-provider-badge ${gitlabTrackingStale ? "detail-source-provider-badge--stale" : ""}`} aria-label={t("taskDetail.gitlabTracking.statusAriaLabel", "GitLab tracking status")}>
<GitBranch aria-hidden="true" />
<span>{gitlabTrackingStatus}</span>
</span>
{gitlabTrackedItem ? (
<a className="detail-source-link detail-source-link--summary detail-source-number" href={gitlabTrackedItem.url} target="_blank" rel="noopener noreferrer">
{`${formatGitLabItemKind(gitlabTrackedItem, t)} ${formatGitLabItemMarker(gitlabTrackedItem)}`}
</a>
) : (
<span className="detail-source-empty">{t("taskDetail.gitlabTracking.unlinked", "No linked GitLab item")}</span>
)}
</div>
<button
type="button"
className="detail-source-toggle"
aria-expanded={gitlabTrackingExpanded}
aria-label={gitlabTrackingExpanded ? t("taskDetail.gitlabTracking.collapse", "Collapse GitLab tracking details") : t("taskDetail.gitlabTracking.expand", "Expand GitLab tracking details")}
onClick={() => setGitlabTrackingExpanded((expanded) => !expanded)}
>
<ChevronRight size={16} className={gitlabTrackingExpanded ? "detail-source-chevron--expanded" : undefined} />
</button>
</div>
{gitlabTrackingExpanded && (
<div className="detail-gitlab-tracking-content">
{gitlabTrackedItem && (
<dl className="detail-source-grid detail-gitlab-tracking-grid">
<div>
<dt>{t("taskDetail.gitlabTracking.item", "Item")}</dt>
<dd><a className="detail-source-link" href={gitlabTrackedItem.url} target="_blank" rel="noopener noreferrer">{gitlabTrackedItem.title || `${formatGitLabItemKind(gitlabTrackedItem, t)} ${formatGitLabItemMarker(gitlabTrackedItem)}`}</a></dd>
</div>
<div>
<dt>{t("taskDetail.gitlabTracking.kind", "Kind")}</dt>
<dd>{formatGitLabItemKind(gitlabTrackedItem, t)}</dd>
</div>
<div>
<dt>{t("taskDetail.gitlabTracking.state", "State")}</dt>
<dd><span className={`detail-gitlab-item-state ${gitlabTrackingStale ? "detail-gitlab-item-state--stale" : ""}`}>{gitlabTrackedItem.state || t("taskDetail.gitlabTracking.stateUnknown", "unknown")}</span></dd>
</div>
<div>
<dt>{t("taskDetail.gitlabTracking.instance", "Instance")}</dt>
<dd>{gitlabTrackedItem.host}</dd>
</div>
{(gitlabTrackedItem.projectPath || gitlabTrackedItem.groupPath) && (
<div>
<dt>{t("taskDetail.gitlabTracking.namespace", "Namespace")}</dt>
<dd>{gitlabTrackedItem.projectPath || gitlabTrackedItem.groupPath}</dd>
</div>
)}
{gitlabTrackedItem.lastSyncedAt && (
<div>
<dt>{t("taskDetail.gitlabTracking.lastSynced", "Last synced")}</dt>
<dd>{formatTimestamp(gitlabTrackedItem.lastSyncedAt)}</dd>
</div>
)}
{gitlabTrackingStale && (
<div>
<dt>{t("taskDetail.gitlabTracking.stale", "Stale")}</dt>
<dd>{gitlabTrackedItem.staleReason || (gitlabTrackedItem.staleAt ? formatTimestamp(gitlabTrackedItem.staleAt) : t("taskDetail.gitlabTracking.staleUnknown", "Sync data is stale"))}</dd>
</div>
)}
</dl>
)}
{gitlabTrackedItem && (
<div className="detail-gitlab-tracking-controls">
<a className="btn btn-sm touch-target" href={gitlabTrackedItem.url} target="_blank" rel="noopener noreferrer" aria-label={t("taskDetail.gitlabTracking.openAriaLabel", "Open linked GitLab item")}>{t("taskDetail.gitlabTracking.openBtn", "Open in GitLab")}</a>
{canEdit && (
<button className="btn btn-sm btn-danger touch-target" onClick={() => void handleUnlinkGitLabItem()} disabled={isSavingGithubTracking}>{t("taskDetail.gitlabTracking.unlinkBtn", "Unlink GitLab item")}</button>
)}
</div>
)}
</div>
)}
</div>
)}
{showGithubTrackingSection && (
<div className="detail-section detail-github-tracking-section">
<div className="detail-source-header">

View File

@@ -11,6 +11,7 @@ import type { Task } from "@fusion/core";
vi.mock("lucide-react", () => ({
Link: () => null,
GitBranch: () => null,
Gitlab: () => null,
Clock: () => null,
Pencil: () => null,
Layers: () => null,
@@ -211,6 +212,54 @@ afterEach(() => {
});
describe("TaskCard", () => {
it("renders GitLab tracking badges for linked and stale items without dropping GitHub badges", () => {
const gitlabItem = {
kind: "merge_request" as const,
url: "https://gitlab.com/acme/app/-/merge_requests/5",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 5,
projectPath: "acme/app",
title: "MR title",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
};
render(
<TaskCard
task={makeTask({
gitlabTracking: { item: gitlabItem },
issueInfo: { url: "https://github.com/runfusion/fusion/issues/1", number: 1, state: "open", title: "GitHub issue" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-gitlab-badge")).toHaveAccessibleName("GitLab MR !5: MR title");
expect(screen.getByRole("link", { name: /GitLab MR !5/ })).toHaveAttribute("href", gitlabItem.url);
expect(screen.getByRole("link", { name: "#1" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/1");
});
it("updates memoized card equality when GitLab tracking changes", () => {
const base = { task: makeTask({ gitlabTracking: undefined }) };
const withGitLab = {
task: makeTask({
gitlabTracking: {
item: {
kind: "project_issue",
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
createdAt: "2026-07-02T00:00:00.000Z",
},
},
}),
};
expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false);
});
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
const onOpenDetailWithTab = vi.fn();
render(

View File

@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { makeTask, mockConfirm, noop, noopDelete, noopMerge, noopMove, noopOpenDetail, setupTaskDetailModalHooks } from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
const projectIssue = {
kind: "project_issue" as const,
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
projectPath: "acme/app",
title: "Project issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
lastSyncedAt: "2026-07-02T00:01:00.000Z",
};
function renderModal(task = makeTask({ column: "todo", gitlabTracking: { item: projectIssue } }), onTaskUpdated = vi.fn()) {
return render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={noop}
/>,
);
}
describe("TaskDetailModal GitLab tracking", () => {
it("renders linked project issue metadata with provider-correct labels and actions", async () => {
const user = userEvent.setup();
renderModal();
expect(screen.getByText("GitLab tracking")).toBeInTheDocument();
expect(screen.getByText("Linked")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Project issue #42" })).toHaveAttribute("href", projectIssue.url);
await user.click(screen.getByRole("button", { name: "Expand GitLab tracking details" }));
expect(screen.getByText("Kind")).toBeInTheDocument();
expect(screen.getByText("gitlab.com")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open linked GitLab item" })).toHaveAttribute("href", projectIssue.url);
expect(screen.getByRole("button", { name: "Unlink GitLab item" })).toBeInTheDocument();
expect(screen.queryByText("GitHub tracking")).not.toBeInTheDocument();
});
it("renders group issues, merge requests, stale state, and GitHub coexistence", async () => {
const user = userEvent.setup();
const staleGroupIssue = {
kind: "group_issue" as const,
url: "https://git.example.test/groups/platform/-/issues/9",
instanceUrl: "https://git.example.test",
host: "git.example.test",
iid: 9,
groupPath: "platform",
title: "Group issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
staleAt: "2026-07-02T01:00:00.000Z",
staleReason: "GitLab sync failed",
};
const task = makeTask({
column: "todo",
gitlabTracking: { item: staleGroupIssue },
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
});
const { rerender } = renderModal(task);
expect(screen.getByText("Stale")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Group issue #9" })).toBeInTheDocument();
expect(screen.getByText("GitHub tracking")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand GitLab tracking details" }));
expect(screen.getByText("GitLab sync failed")).toBeInTheDocument();
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "todo", gitlabTracking: { item: { ...projectIssue, kind: "merge_request", iid: 5, url: "https://gitlab.com/acme/app/-/merge_requests/5", title: "MR" } } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("link", { name: "Merge request !5" })).toBeInTheDocument();
});
it("unlinks after confirmation and does not render empty GitLab shells", async () => {
const user = userEvent.setup();
const onTaskUpdated = vi.fn();
const { updateTask } = await import("../../api");
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ column: "todo", gitlabTracking: { unlinkedAt: "2026-07-02T00:00:00.000Z" } }) as any);
mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
renderModal(undefined, onTaskUpdated);
await user.click(screen.getByRole("button", { name: "Expand GitLab tracking details" }));
await user.click(screen.getByRole("button", { name: "Unlink GitLab item" }));
expect(updateTask).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Unlink GitLab item" }));
await waitFor(() => expect(updateTask).toHaveBeenCalledWith("FN-099", { gitlabTracking: { item: null } }, undefined));
expect(onTaskUpdated).toHaveBeenCalled();
});
it("omits GitLab tracking section when metadata is empty", () => {
renderModal(makeTask({ column: "todo", gitlabTracking: undefined }));
expect(screen.queryByTestId("detail-gitlab-tracking-section")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/GitLab/i)).not.toBeInTheDocument();
});
});

View File

@@ -98,6 +98,7 @@ vi.mock("lucide-react", () => ({
Workflow: () => null,
GitMerge: () => null,
GitBranch: () => null,
Gitlab: () => null,
AlertTriangle: () => null,
Play: () => null,
Flag: () => null,

View File

@@ -2221,6 +2221,15 @@ input[type="range"]:focus-visible {
color: var(--text-muted);
}
/*
FNXC:GitLabTracking 2026-07-02-00:00:
Task cards reuse the compact badge chip dimensions for GitLab links, but stale GitLab sync metadata must use the warning token so operators can distinguish stale tracker snapshots from open/closed GitHub status.
*/
.card-gitlab-badge--stale {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
color: var(--color-warning);
}
.pr-number {
color: var(--text-muted);
font-size: 0.875rem;

View File

@@ -3046,6 +3046,142 @@ describe("PATCH /tasks/:id", () => {
expect(res.body.error).toContain("sourceIssue.externalIssueId");
});
it("forwards gitlabTracking updates for project issues, group issues, and merge requests", async () => {
const items = [
{
kind: "project_issue",
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
projectId: 7,
projectPath: "acme/app",
title: "Project issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
lastSyncedAt: "2026-07-02T00:01:00.000Z",
},
{
kind: "group_issue",
url: "https://git.example.test/groups/platform/-/issues/9",
instanceUrl: "https://git.example.test",
host: "git.example.test",
iid: 9,
groupPath: "platform",
title: "Group issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
staleAt: "2026-07-02T01:00:00.000Z",
staleReason: "GitLab sync failed",
},
{
kind: "merge_request",
url: "https://gitlab.example.org/acme/app/-/merge_requests/5",
instanceUrl: "https://gitlab.example.org",
host: "gitlab.example.org",
iid: 5,
projectPath: "acme/app",
title: "Merge request",
state: "merged",
createdAt: "2026-07-02T00:00:00.000Z",
},
];
for (const item of items) {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, gitlabTracking: { item } });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ gitlabTracking: { item } }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenLastCalledWith("KB-001", {
gitlabTracking: { item: expect.objectContaining({ kind: item.kind, iid: item.iid, host: item.host }) },
});
}
});
it("forwards gitlabTracking unlink without triggering GitHub issue creation", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "runfusion",
repo: "fusion",
number: 102,
htmlUrl: "https://github.com/runfusion/fusion/issues/102",
createdAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, gitlabTracking: { unlinkedAt: "2026-07-02T00:00:00.000Z" } });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ gitlabTracking: { item: null } }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { gitlabTracking: { item: null } });
expect(createIssueSpy).not.toHaveBeenCalled();
createIssueSpy.mockRestore();
});
it("returns 400 for malformed gitlabTracking metadata", async () => {
const invalidPayloads = [
{ gitlabTracking: { item: { kind: "epic", url: "https://gitlab.com/acme/app/-/issues/1", instanceUrl: "https://gitlab.com", host: "gitlab.com", iid: 1, createdAt: "2026-07-02T00:00:00.000Z" } } },
{ gitlabTracking: { item: { kind: "project_issue", url: "notaurl", instanceUrl: "https://gitlab.com", host: "gitlab.com", iid: 1, createdAt: "2026-07-02T00:00:00.000Z" } } },
{ gitlabTracking: { item: { kind: "project_issue", url: "https://gitlab.com/acme/app/-/issues/1", instanceUrl: "https://gitlab.com", host: "gitlab.com", iid: -1, createdAt: "2026-07-02T00:00:00.000Z" } } },
{ gitlabTracking: { item: { kind: "project_issue", url: "https://gitlab.com/acme/app/-/issues/1", instanceUrl: "https://gitlab.com", host: "example.com", iid: 1, createdAt: "2026-07-02T00:00:00.000Z" } } },
];
for (const payload of invalidPayloads) {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify(payload), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("gitlabTracking");
}
});
it("PATCH persists gitlabTracking with a real store without clearing GitHub fields", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "kb-routes-patch-gitlab-tracking-"));
const globalDir = mkdtempSync(join(tmpdir(), "kb-routes-patch-gitlab-tracking-global-"));
const realStore = new CoreTaskStore(rootDir, globalDir, { inMemoryDb: true });
await realStore.init();
try {
const created = await realStore.createTask({
description: "route gitlab patch flow",
column: "todo",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
});
const item = {
kind: "project_issue",
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
projectPath: "acme/app",
title: "Project issue",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(realStore));
const res = await REQUEST(app, "PATCH", `/api/tasks/${created.id}`, JSON.stringify({ gitlabTracking: { item } }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.gitlabTracking?.item).toMatchObject({ kind: "project_issue", iid: 42, host: "gitlab.com" });
expect(res.body.githubTracking?.repoOverride).toBe("runfusion/fusion");
const persisted = await realStore.getTask(created.id);
expect(persisted.gitlabTracking?.item?.url).toBe("https://gitlab.com/acme/app/-/issues/42");
expect(persisted.githubTracking?.repoOverride).toBe("runfusion/fusion");
} finally {
realStore.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
}
});
it("forwards githubTracking updates including null issue unlink", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL });

View File

@@ -1,4 +1,4 @@
import type { Task, TaskSourceIssue } from "@fusion/core";
import type { Task, TaskGitLabTracking, TaskSourceIssue } from "@fusion/core";
import type { ResolvedGitlabAuth } from "./gitlab-auth.js";
export type GitLabResourceType = "project_issue" | "group_issue" | "merge_request";
@@ -267,12 +267,13 @@ export function buildGitLabTaskProvenance(args: {
item: GitLabIssue | GitLabMergeRequest;
projectInput?: string | number;
groupInput?: string | number;
}): { sourceIssue: TaskSourceIssue; sourceMetadata: Record<string, unknown> } {
}): { sourceIssue: TaskSourceIssue; gitlabTracking: TaskGitLabTracking; sourceMetadata: Record<string, unknown> } {
const { auth, resourceType, item } = args;
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",
@@ -281,6 +282,25 @@ export function buildGitLabTaskProvenance(args: {
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 } : {}),
...("groupPath" in item && item.groupPath !== undefined ? { groupPath: item.groupPath } : {}),
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,

View File

@@ -87,6 +87,7 @@ async function importItem(ctx: ApiRoutesContext, req: Parameters<ApiRoutesContex
column: "triage",
dependencies: [],
sourceIssue: provenance.sourceIssue,
gitlabTracking: provenance.gitlabTracking,
source: { sourceType: "gitlab_import", sourceMetadata: provenance.sourceMetadata },
});
await store.logEntry(task.id, args.resourceType === "merge_request" ? "Imported merge request from GitLab" : "Imported from GitLab", args.item.webUrl);

View File

@@ -3212,7 +3212,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body;
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, gitlabTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -3378,6 +3378,105 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
}
let validatedGitLabTracking: (Omit<import("@fusion/core").TaskGitLabTracking, "item"> & { item?: import("@fusion/core").TaskGitLabTrackedItem | null }) | null | undefined;
if (hasBodyField("gitlabTracking")) {
if (gitlabTracking === null) {
validatedGitLabTracking = null;
} else if (typeof gitlabTracking !== "object" || Array.isArray(gitlabTracking)) {
throw new Error("gitlabTracking must be an object or null");
} else {
const candidate = gitlabTracking as { item?: unknown; unlinkedAt?: unknown };
if (candidate.unlinkedAt !== undefined && candidate.unlinkedAt !== null && typeof candidate.unlinkedAt !== "string") {
throw new Error("gitlabTracking.unlinkedAt must be a string when provided");
}
if (candidate.item === null) {
validatedGitLabTracking = { item: null };
} else if (candidate.item === undefined) {
validatedGitLabTracking = {
...(typeof candidate.unlinkedAt === "string" && candidate.unlinkedAt.trim().length > 0 ? { unlinkedAt: candidate.unlinkedAt.trim() } : {}),
};
} else if (typeof candidate.item !== "object" || Array.isArray(candidate.item)) {
throw new Error("gitlabTracking.item must be an object or null");
} else {
const item = candidate.item as Record<string, unknown>;
const kind = item.kind;
if (kind !== "project_issue" && kind !== "group_issue" && kind !== "merge_request") {
throw new Error("gitlabTracking.item.kind must be project_issue, group_issue, or merge_request");
}
if (typeof item.url !== "string" || item.url.trim().length === 0) {
throw new Error("gitlabTracking.item.url must be a non-empty string");
}
if (typeof item.instanceUrl !== "string" || item.instanceUrl.trim().length === 0) {
throw new Error("gitlabTracking.item.instanceUrl must be a non-empty string");
}
let parsedUrl: URL;
let parsedInstanceUrl: URL;
try {
parsedUrl = new URL(item.url.trim());
parsedInstanceUrl = new URL(item.instanceUrl.trim());
} catch {
throw new Error("gitlabTracking.item.url and instanceUrl must be valid URLs");
}
if (!["http:", "https:"].includes(parsedUrl.protocol) || !["http:", "https:"].includes(parsedInstanceUrl.protocol)) {
throw new Error("gitlabTracking.item.url and instanceUrl must be http(s) URLs");
}
if (typeof item.host !== "string" || item.host.trim().length === 0) {
throw new Error("gitlabTracking.item.host must be a non-empty string");
}
if (item.host.trim() !== parsedUrl.host || parsedInstanceUrl.host !== parsedUrl.host) {
throw new Error("gitlabTracking.item.host must match the GitLab URL host");
}
if (typeof item.iid !== "number" || !Number.isInteger(item.iid) || item.iid <= 0) {
throw new Error("gitlabTracking.item.iid must be a positive integer");
}
const optionalNumberFields = ["id", "projectId"];
for (const field of optionalNumberFields) {
if (item[field] !== undefined && item[field] !== null && (typeof item[field] !== "number" || !Number.isInteger(item[field]) || Number(item[field]) <= 0)) {
throw new Error(`gitlabTracking.item.${field} must be a positive integer when provided`);
}
}
const optionalStringFields = ["projectPath", "groupPath", "title", "state", "createdAt", "linkedAt", "lastSyncedAt", "staleAt", "staleReason"];
for (const field of optionalStringFields) {
if (item[field] !== undefined && item[field] !== null && typeof item[field] !== "string") {
throw new Error(`gitlabTracking.item.${field} must be a string when provided`);
}
}
if (typeof item.createdAt !== "string" || item.createdAt.trim().length === 0) {
throw new Error("gitlabTracking.item.createdAt must be a non-empty string");
}
if (item.groupId !== undefined && item.groupId !== null) {
const groupIdType = typeof item.groupId;
if (!((groupIdType === "string" && String(item.groupId).trim().length > 0) || (groupIdType === "number" && Number.isInteger(item.groupId) && Number(item.groupId) > 0))) {
throw new Error("gitlabTracking.item.groupId must be a non-empty string or positive integer when provided");
}
}
validatedGitLabTracking = {
item: {
kind,
url: parsedUrl.toString(),
instanceUrl: parsedInstanceUrl.origin,
host: parsedUrl.host,
iid: item.iid,
...(typeof item.id === "number" ? { id: item.id } : {}),
...(typeof item.projectId === "number" ? { projectId: item.projectId } : {}),
...(typeof item.projectPath === "string" && item.projectPath.trim().length > 0 ? { projectPath: item.projectPath.trim() } : {}),
...(typeof item.groupId === "number" || typeof item.groupId === "string" ? { groupId: typeof item.groupId === "string" ? item.groupId.trim() : item.groupId } : {}),
...(typeof item.groupPath === "string" && item.groupPath.trim().length > 0 ? { groupPath: item.groupPath.trim() } : {}),
...(typeof item.title === "string" && item.title.trim().length > 0 ? { title: item.title.trim() } : {}),
...(typeof item.state === "string" && item.state.trim().length > 0 ? { state: item.state.trim() } : {}),
createdAt: item.createdAt.trim(),
...(typeof item.linkedAt === "string" && item.linkedAt.trim().length > 0 ? { linkedAt: item.linkedAt.trim() } : {}),
...(typeof item.lastSyncedAt === "string" && item.lastSyncedAt.trim().length > 0 ? { lastSyncedAt: item.lastSyncedAt.trim() } : {}),
...(typeof item.staleAt === "string" && item.staleAt.trim().length > 0 ? { staleAt: item.staleAt.trim() } : {}),
...(typeof item.staleReason === "string" && item.staleReason.trim().length > 0 ? { staleReason: item.staleReason.trim() } : {}),
},
...(typeof candidate.unlinkedAt === "string" && candidate.unlinkedAt.trim().length > 0 ? { unlinkedAt: candidate.unlinkedAt.trim() } : {}),
};
}
}
}
let validatedOverlapBlockedBy: string | null | undefined;
if (hasBodyField("overlapBlockedBy")) {
if (overlapBlockedBy === null || overlapBlockedBy === undefined) {
@@ -3431,6 +3530,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (hasBodyField("githubTracking")) {
(updates as Record<string, unknown>).githubTracking = validatedGithubTracking;
}
if (hasBodyField("gitlabTracking")) {
(updates as Record<string, unknown>).gitlabTracking = validatedGitLabTracking;
}
if (hasBodyField("overlapBlockedBy")) updates.overlapBlockedBy = validatedOverlapBlockedBy;
if (hasBodyField("status")) updates.status = validatedStatus;
if (dismissNearDuplicate === true) {
@@ -3480,7 +3582,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be a non-empty string") || (err instanceof Error ? err.message : String(err)).includes("must be a string or null") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("must be a boolean") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") || (err instanceof Error ? err.message : String(err)).includes("executionMode must be one of") || (err instanceof Error ? err.message : String(err)).includes("priority must be one of") || (err instanceof Error ? err.message : String(err)).includes("sourceIssue") || (err instanceof Error ? err.message : String(err)).includes("status may only be cleared") ? 400 : 500;
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be a non-empty string") || (err instanceof Error ? err.message : String(err)).includes("must be a string or null") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("must be a boolean") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") || (err instanceof Error ? err.message : String(err)).includes("executionMode must be one of") || (err instanceof Error ? err.message : String(err)).includes("priority must be one of") || (err instanceof Error ? err.message : String(err)).includes("sourceIssue") || (err instanceof Error ? err.message : String(err)).includes("gitlabTracking") || (err instanceof Error ? err.message : String(err)).includes("status may only be cleared") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});

View File

@@ -7342,6 +7342,35 @@
"executionTiming": "Execution Timing",
"executionTimingMetricsAria": "Execution timing metrics",
"firstUsed": "First used",
"gitlabTracking": {
"collapse": "Collapse GitLab tracking details",
"expand": "Expand GitLab tracking details",
"instance": "Instance",
"item": "Item",
"itemUnlinked": "GitLab item unlinked",
"kind": "Kind",
"kindGroupIssue": "Group issue",
"kindMergeRequest": "Merge request",
"kindProjectIssue": "Project issue",
"label": "GitLab tracking",
"lastSynced": "Last synced",
"namespace": "Namespace",
"openAriaLabel": "Open linked GitLab item",
"openBtn": "Open in GitLab",
"stale": "Stale",
"staleUnknown": "Sync data is stale",
"state": "State",
"stateUnknown": "unknown",
"statusAriaLabel": "GitLab tracking status",
"statusLinked": "Linked",
"statusStale": "Stale",
"statusUnlinked": "Unlinked",
"unlinkBtn": "Unlink GitLab item",
"unlinkConfirm": "Unlink",
"unlinkMessage": "This removes the local GitLab tracking link. The GitLab issue or merge request itself will not be modified.",
"unlinkTitle": "Unlink GitLab item?",
"unlinked": "No linked GitLab item"
},
"githubTracking": {
"addTitleBeforeCreating": "Add a title before creating a tracking issue",
"checking": "Checking tracking status",
@@ -7553,6 +7582,8 @@
"expand": "Expand source issue details",
"githubAriaLabel": "GitHub source issue",
"githubBadge": "GitHub",
"gitlabAriaLabel": "GitLab source item",
"gitlabBadge": "GitLab",
"identifier": "Issue Identifier",
"label": "Source issue",
"none": "(none)",

View File

@@ -7360,6 +7360,35 @@ export default interface Resources {
"executionTiming": "Execution Timing",
"executionTimingMetricsAria": "Execution timing metrics",
"firstUsed": "First used",
"gitlabTracking": {
"collapse": "Collapse GitLab tracking details",
"expand": "Expand GitLab tracking details",
"instance": "Instance",
"item": "Item",
"itemUnlinked": "GitLab item unlinked",
"kind": "Kind",
"kindGroupIssue": "Group issue",
"kindMergeRequest": "Merge request",
"kindProjectIssue": "Project issue",
"label": "GitLab tracking",
"lastSynced": "Last synced",
"namespace": "Namespace",
"openAriaLabel": "Open linked GitLab item",
"openBtn": "Open in GitLab",
"stale": "Stale",
"staleUnknown": "Sync data is stale",
"state": "State",
"stateUnknown": "unknown",
"statusAriaLabel": "GitLab tracking status",
"statusLinked": "Linked",
"statusStale": "Stale",
"statusUnlinked": "Unlinked",
"unlinkBtn": "Unlink GitLab item",
"unlinkConfirm": "Unlink",
"unlinkMessage": "This removes the local GitLab tracking link. The GitLab issue or merge request itself will not be modified.",
"unlinkTitle": "Unlink GitLab item?",
"unlinked": "No linked GitLab item"
},
"githubTracking": {
"addTitleBeforeCreating": "Add a title before creating a tracking issue",
"checking": "Checking tracking status",
@@ -7571,6 +7600,8 @@ export default interface Resources {
"expand": "Expand source issue details",
"githubAriaLabel": "GitHub source issue",
"githubBadge": "GitHub",
"gitlabAriaLabel": "GitLab source item",
"gitlabBadge": "GitLab",
"identifier": "Issue Identifier",
"label": "Source issue",
"none": "(none)",