diff --git a/.changeset/fn-7425-gitlab-tracking.md b/.changeset/fn-7425-gitlab-tracking.md new file mode 100644 index 0000000000..fb9ac548a8 --- /dev/null +++ b/.changeset/fn-7425-gitlab-tracking.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2f790cd380..b9f52f74f5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 32a7db3446..17cbe56c15 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index e82c6dd079..6eb4fb6554 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -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)"); diff --git a/packages/core/src/__tests__/store-gitlab-tracking.test.ts b/packages/core/src/__tests__/store-gitlab-tracking.test.ts new file mode 100644 index 0000000000..dde4546dd5 --- /dev/null +++ b/packages/core/src/__tests__/store-gitlab-tracking.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 58dfde117c..1ddd53eb57 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -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"); + }); + } + } /** diff --git a/packages/core/src/gitlab-tracking.ts b/packages/core/src/gitlab-tracking.ts new file mode 100644 index 0000000000..1d578fe57c --- /dev/null +++ b/packages/core/src/gitlab-tracking.ts @@ -0,0 +1,10 @@ +import type { TaskGitLabTrackedItem } from "./types.js"; + +export function formatGitLabTrackedItemRef(item: Pick): string { + const marker = item.kind === "merge_request" ? "!" : "#"; + return `${item.host} ${item.kind} ${marker}${item.iid}`; +} + +export function isGitLabTrackingStale(item: Pick | undefined): boolean { + return Boolean(item?.staleAt || item?.staleReason); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31c16fab82..54d6addacb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d5e93d8a63..d348593d60 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { })(), issueInfo: fromJson(row.issueInfo), githubTracking: fromJson(row.githubTracking) ?? undefined, + gitlabTracking: fromJson(row.gitlabTracking) ?? undefined, sourceIssue: (() => { if ( row.sourceIssueProvider === null @@ -2301,6 +2304,7 @@ export class TaskStore extends EventEmitter { 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 { 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 { "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 { "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; 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 | 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; 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 | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit & { 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 { 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 { + 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 { + 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 { + 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" }], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4c77063087..710a278140 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 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 */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e7a4ac4fca..e8e61006e9 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 & { item?: TaskGitLabTrackedItem | null }) | null; dismissNearDuplicate?: boolean; }, projectId?: string, diff --git a/packages/dashboard/app/components/GitLabBadge.tsx b/packages/dashboard/app/components/GitLabBadge.tsx new file mode 100644 index 0000000000..7e54a75ef3 --- /dev/null +++ b/packages/dashboard/app/components/GitLabBadge.tsx @@ -0,0 +1,33 @@ +import { GitBranch, AlertTriangle } from "lucide-react"; +import type { TaskGitLabTrackedItem } from "@fusion/core"; + +export function formatGitLabBadgeKind(item: Pick): string { + if (item.kind === "merge_request") return "MR"; + if (item.kind === "group_issue") return "Group issue"; + return "Issue"; +} + +export function formatGitLabBadgeMarker(item: Pick): 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 ( + + {stale ? + ); +} diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 5f37035a0a..1e94adaf94 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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 && ( + + )} {prNode && ( prNode.state === "failed" ? ( + + {gitlabTrackingExpanded && ( +
+ {gitlabTrackedItem && ( +
+ +
+
{t("taskDetail.gitlabTracking.kind", "Kind")}
+
{formatGitLabItemKind(gitlabTrackedItem, t)}
+
+
+
{t("taskDetail.gitlabTracking.state", "State")}
+
{gitlabTrackedItem.state || t("taskDetail.gitlabTracking.stateUnknown", "unknown")}
+
+
+
{t("taskDetail.gitlabTracking.instance", "Instance")}
+
{gitlabTrackedItem.host}
+
+ {(gitlabTrackedItem.projectPath || gitlabTrackedItem.groupPath) && ( +
+
{t("taskDetail.gitlabTracking.namespace", "Namespace")}
+
{gitlabTrackedItem.projectPath || gitlabTrackedItem.groupPath}
+
+ )} + {gitlabTrackedItem.lastSyncedAt && ( +
+
{t("taskDetail.gitlabTracking.lastSynced", "Last synced")}
+
{formatTimestamp(gitlabTrackedItem.lastSyncedAt)}
+
+ )} + {gitlabTrackingStale && ( +
+
{t("taskDetail.gitlabTracking.stale", "Stale")}
+
{gitlabTrackedItem.staleReason || (gitlabTrackedItem.staleAt ? formatTimestamp(gitlabTrackedItem.staleAt) : t("taskDetail.gitlabTracking.staleUnknown", "Sync data is stale"))}
+
+ )} +
+ )} + {gitlabTrackedItem && ( +
+ {t("taskDetail.gitlabTracking.openBtn", "Open in GitLab")} + {canEdit && ( + + )} +
+ )} +
+ )} + + )} {showGithubTrackingSection && (
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 416a9b0e5a..ddd044f34e 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + 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( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.gitlab-tracking.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.gitlab-tracking.test.tsx new file mode 100644 index 0000000000..6d44d10aae --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.gitlab-tracking.test.tsx @@ -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( + , + ); +} + +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( + , + ); + 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(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 571c792dc7..fd2a91cb13 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -98,6 +98,7 @@ vi.mock("lucide-react", () => ({ Workflow: () => null, GitMerge: () => null, GitBranch: () => null, + Gitlab: () => null, AlertTriangle: () => null, Play: () => null, Flag: () => null, diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index d911c6fddb..e012f690ce 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -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; diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 3707bbda28..f978109ce4 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -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).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).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).mockResolvedValue({ ...FAKE_TASK_DETAIL }); diff --git a/packages/dashboard/src/gitlab.ts b/packages/dashboard/src/gitlab.ts index 94b26ea9c7..d190699cb5 100644 --- a/packages/dashboard/src/gitlab.ts +++ b/packages/dashboard/src/gitlab.ts @@ -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 } { +}): { sourceIssue: TaskSourceIssue; gitlabTracking: TaskGitLabTracking; sourceMetadata: Record } { 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, diff --git a/packages/dashboard/src/routes/register-gitlab.ts b/packages/dashboard/src/routes/register-gitlab.ts index 4cee8a9e0b..2233ee16ce 100644 --- a/packages/dashboard/src/routes/register-gitlab.ts +++ b/packages/dashboard/src/routes/register-gitlab.ts @@ -87,6 +87,7 @@ async function importItem(ctx: ApiRoutesContext, req: Parameters { 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 & { 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; + 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).githubTracking = validatedGithubTracking; } + if (hasBodyField("gitlabTracking")) { + (updates as Record).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)); } }); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 899f9bd5a8..00256e87dd 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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)", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index c2124e9085..289bf4b173 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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)",