From 573876603f95528c89af5d26757cf329eae9c745 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 08:59:47 -0700 Subject: [PATCH] FN-7422: add GitLab URL configuration Add typed GitLab instance and API URL settings for GitLab.com and self-managed instances. - Add core GitLab URL defaults, validation, normalization, and project/global inheritance. - Expose project and global GitLab instance/API URL fields in settings with save-split handling. - Cover configuration resolution, settings parity, and settings UI save behavior with tests. - Document the new settings and add a changeset for the published CLI package. Files changed: .changeset/fn-7422-gitlab-configuration.md | 7 ++ docs/settings-reference.md | 6 +- docs/task-management.md | 2 +- packages/core/src/__tests__/gitlab-config.test.ts | 74 +++++++++++++++++++ .../core/src/__tests__/settings-parity.test.ts | 22 +++++- packages/core/src/gitlab-config.ts | 79 +++++++++++++++++++++ packages/core/src/index.ts | 4 +- packages/core/src/settings-schema.ts | 4 ++ packages/core/src/types.ts | 20 ++++++ .../app/__tests__/settings-save-split.test.ts | 53 ++++++++++++++ .../dashboard/app/components/SettingsModal.tsx | 20 ++++++ .../__tests__/SettingsModal.general.test.tsx | 82 ++++++++++++++++++++++ .../app/components/settings/save-split.ts | 6 ++ .../settings/sections/GeneralSection.tsx | 15 ++++ .../settings/sections/GlobalGeneralSection.tsx | 14 ++++ 15 files changed, 402 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7422 Fusion-Task-Lineage: 0110b4b6-71b0-4a62-afaf-61af65ebef03 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7422-gitlab-configuration.md | 7 ++ docs/settings-reference.md | 6 +- docs/task-management.md | 2 +- .../core/src/__tests__/gitlab-config.test.ts | 74 +++++++++++++++++ .../src/__tests__/settings-parity.test.ts | 22 ++++- packages/core/src/gitlab-config.ts | 79 ++++++++++++++++++ packages/core/src/index.ts | 4 +- packages/core/src/settings-schema.ts | 4 + packages/core/src/types.ts | 20 +++++ .../app/__tests__/settings-save-split.test.ts | 53 ++++++++++++ .../app/components/SettingsModal.tsx | 20 +++++ .../__tests__/SettingsModal.general.test.tsx | 82 +++++++++++++++++++ .../app/components/settings/save-split.ts | 6 ++ .../settings/sections/GeneralSection.tsx | 15 ++++ .../sections/GlobalGeneralSection.tsx | 14 ++++ 15 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-7422-gitlab-configuration.md create mode 100644 packages/core/src/__tests__/gitlab-config.test.ts create mode 100644 packages/core/src/gitlab-config.ts diff --git a/.changeset/fn-7422-gitlab-configuration.md b/.changeset/fn-7422-gitlab-configuration.md new file mode 100644 index 0000000000..80f722526e --- /dev/null +++ b/.changeset/fn-7422-gitlab-configuration.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add GitLab instance URL settings for GitLab.com and self-managed servers. +category: feature +dev: Adds typed GitLab web/API URL configuration and dashboard controls for later GitLab integration subtasks. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c398bf4c2f..a878860368 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -91,6 +91,8 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, and re-run that refresh after saving an `opencode`/`opencode-go` API key in Dashboard Settings, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. | | `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). | +| `gitlabInstanceUrl` | `string` | `undefined` (effective `https://gitlab.com`) | Global fallback GitLab web instance URL. Blank/unset defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Projects can override this key. | +| `gitlabApiBaseUrl` | `string` | `undefined` (effective `https://gitlab.com/api/v4`) | Global fallback GitLab REST API base URL. Blank/unset derives `/api/v4`, preserving self-managed path prefixes such as `https://example.com/gitlab` → `https://example.com/gitlab/api/v4`. Values are trimmed and must be absolute `http://` or `https://` URLs without userinfo. | | `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | | `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. | @@ -586,11 +588,13 @@ Default notes: | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | +| `gitlabInstanceUrl` | `string` | `undefined` (effective global fallback, then `https://gitlab.com`) | Project GitLab web instance URL for GitLab.com or self-managed GitLab. Blank/unset inherits global `gitlabInstanceUrl` and then defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Dashboard location: **Settings → Project → General → GitLab Configuration**. | +| `gitlabApiBaseUrl` | `string` | `undefined` (effective global fallback, then `/api/v4`) | Optional project GitLab REST API base URL. Blank/unset inherits global `gitlabApiBaseUrl`; if still unset, Fusion derives `/api/v4`, preserving self-managed path prefixes. Override only for API gateways with a different absolute HTTP(S) base URL. | | `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. | | `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). | | `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"` (takes precedence over server startup token for tracking flows). | -Forward-looking GitLab auth/settings parity is inventoried in [GitLab Parity Inventory](./gitlab-parity-inventory.md); no GitLab settings keys exist until a later implementation task adds them. +GitLab configuration examples: leave both fields blank for GitLab.com (`https://gitlab.com`, API `https://gitlab.com/api/v4`); set only `gitlabInstanceUrl=https://gitlab.example.com/gitlab` for a self-managed path-prefix install (API derives `https://gitlab.example.com/gitlab/api/v4`); set both fields when a self-managed API gateway differs from the web URL. FN-7422 adds URL configuration only — GitLab token auth, import, tracking, comments, auto-close, Command Center signals, research/search providers, and star-prompt behavior remain deferred to later GitLab subtasks tracked from [GitLab Parity Inventory](./gitlab-parity-inventory.md). | `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. | | `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. | diff --git a/docs/task-management.md b/docs/task-management.md index a54079a13f..a43fef598e 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -674,7 +674,7 @@ Recovery/backfill guidance: ## GitHub Issue Import and PR Creation -Forward-looking GitLab import/tracking parity is mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md); that document is an implementation plan only and does not imply runtime GitLab support yet. +GitLab instance/API URL configuration is available in Settings for GitLab.com and self-managed GitLab (`gitlabInstanceUrl`, optional `gitlabApiBaseUrl`). Forward-looking GitLab import/tracking parity is mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md); GitLab token auth, issue/MR import, tracking, comments, auto-close, Command Center signals, research/search support, and star-prompt behavior are not implemented by this configuration-only step. Import issues: diff --git a/packages/core/src/__tests__/gitlab-config.test.ts b/packages/core/src/__tests__/gitlab-config.test.ts new file mode 100644 index 0000000000..7d92eab843 --- /dev/null +++ b/packages/core/src/__tests__/gitlab-config.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_GITLAB_API_BASE_URL, + DEFAULT_GITLAB_INSTANCE_URL, + resolveGitlabConfig, +} from "../gitlab-config.js"; + +/* +FNXC:GitLabConfiguration 2026-07-02-00:00: +These tests pin FN-7422 as configuration-only groundwork: GitLab.com is the blank default, project settings override global fallbacks, and invalid URL forms fail before future GitLab API clients can use persisted settings. +*/ +describe("resolveGitlabConfig", () => { + it("defaults to GitLab.com when no settings are configured", () => { + expect(resolveGitlabConfig()).toEqual({ + instanceUrl: DEFAULT_GITLAB_INSTANCE_URL, + apiBaseUrl: DEFAULT_GITLAB_API_BASE_URL, + }); + }); + + it("uses project overrides before global fallbacks", () => { + expect( + resolveGitlabConfig({ + global: { gitlabInstanceUrl: "https://global.example/gitlab", gitlabApiBaseUrl: "https://global.example/api/v4" }, + project: { gitlabInstanceUrl: "https://project.example/gitlab", gitlabApiBaseUrl: "https://project.example/rest" }, + }), + ).toEqual({ + instanceUrl: "https://project.example/gitlab", + apiBaseUrl: "https://project.example/rest", + }); + }); + + it("uses global fallbacks when project settings are blank", () => { + expect( + resolveGitlabConfig({ + global: { gitlabInstanceUrl: " https://global.example/gitlab/ ", gitlabApiBaseUrl: " https://global.example/gitlab/api/v4/ " }, + project: { gitlabInstanceUrl: " ", gitlabApiBaseUrl: "" }, + }), + ).toEqual({ + instanceUrl: "https://global.example/gitlab", + apiBaseUrl: "https://global.example/gitlab/api/v4", + }); + }); + + it("derives the API base URL from a self-managed path prefix", () => { + expect(resolveGitlabConfig({ project: { gitlabInstanceUrl: "https://example.com/gitlab/" } })).toEqual({ + instanceUrl: "https://example.com/gitlab", + apiBaseUrl: "https://example.com/gitlab/api/v4", + }); + }); + + it("allows explicit API base URL overrides", () => { + expect( + resolveGitlabConfig({ + project: { gitlabInstanceUrl: "https://gitlab.example", gitlabApiBaseUrl: "https://api.example/custom/v4/" }, + }), + ).toEqual({ instanceUrl: "https://gitlab.example", apiBaseUrl: "https://api.example/custom/v4" }); + }); + + it("treats blank strings as cleared defaults", () => { + expect(resolveGitlabConfig({ project: { gitlabInstanceUrl: " ", gitlabApiBaseUrl: "\t" } })).toEqual({ + instanceUrl: DEFAULT_GITLAB_INSTANCE_URL, + apiBaseUrl: DEFAULT_GITLAB_API_BASE_URL, + }); + }); + + it.each([ + ["GitLab instance URL", { project: { gitlabInstanceUrl: "ssh://gitlab.example" } }], + ["GitLab instance URL", { project: { gitlabInstanceUrl: "https://user:pass@gitlab.example" } }], + ["GitLab API base URL", { project: { gitlabApiBaseUrl: "ftp://gitlab.example/api/v4" } }], + ["GitLab API base URL", { project: { gitlabApiBaseUrl: "https://token@gitlab.example/api/v4" } }], + ])("rejects invalid %s settings", (_label, input) => { + expect(() => resolveGitlabConfig(input)).toThrow(/GitLab .* URL/); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index a513b11b17..3c7f1008e4 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -81,6 +81,21 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("agentMemoryInclusionMode")).toBe(false); }); + it("keeps GitLab URL configuration dual-scoped with blank defaults", () => { + expect(DEFAULT_GLOBAL_SETTINGS.gitlabInstanceUrl).toBeUndefined(); + expect(DEFAULT_GLOBAL_SETTINGS.gitlabApiBaseUrl).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.gitlabInstanceUrl).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.gitlabApiBaseUrl).toBeUndefined(); + expect(isGlobalSettingsKey("gitlabInstanceUrl")).toBe(true); + expect(isGlobalSettingsKey("gitlabApiBaseUrl")).toBe(true); + expect(isProjectSettingsKey("gitlabInstanceUrl")).toBe(true); + expect(isProjectSettingsKey("gitlabApiBaseUrl")).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain("gitlabInstanceUrl"); + expect(PROJECT_SETTINGS_KEYS).toContain("gitlabApiBaseUrl"); + expect(GLOBAL_SETTINGS_KEYS).toContain("gitlabInstanceUrl"); + expect(GLOBAL_SETTINGS_KEYS).toContain("gitlabApiBaseUrl"); + }); + it("defaults persisted thinking logs to disabled", () => { expect(DEFAULT_GLOBAL_SETTINGS.persistAgentThinkingLog).toBe(false); expect(DEFAULT_GLOBAL_SETTINGS.persistAgentThinkingLogPermanent).toBe(false); @@ -406,13 +421,16 @@ describe("settings key parity", () => { // FNXC:SettingsScopeParity 2026-06-26-17:35: // mcpServers is intentionally dual-scoped (FN-7077, "inject configured MCP servers across // agent surfaces"): a global default applies to every project while a project override - // tailors the MCP server set per project. Keep it on the intentional shared-keys allow-list - // in GLOBAL_SETTINGS_KEYS order so this parity guard does not flag the deliberate overlap. + // tailors the MCP server set per project. GitLab URL settings are also dual-scoped (FN-7422) + // so operators can set an organization-wide self-managed instance while individual projects + // can override hosts or API prefixes. Keep this allow-list in GLOBAL_SETTINGS_KEYS order. expect(overlap).toEqual([ "testMode", "mergeRequestContractShadowEnabled", "taskTokenBudget", "githubTrackingDefaultRepo", + "gitlabInstanceUrl", + "gitlabApiBaseUrl", "mcpServers", "worktrunk", "owningNodeHandoffPolicy", diff --git a/packages/core/src/gitlab-config.ts b/packages/core/src/gitlab-config.ts new file mode 100644 index 0000000000..197aad7a43 --- /dev/null +++ b/packages/core/src/gitlab-config.ts @@ -0,0 +1,79 @@ +import type { GlobalSettings, ProjectSettings } from "./types.js"; + +export const DEFAULT_GITLAB_INSTANCE_URL = "https://gitlab.com"; +export const DEFAULT_GITLAB_API_BASE_URL = "https://gitlab.com/api/v4"; + +export interface GitlabConfigSettingsSource { + gitlabInstanceUrl?: string; + gitlabApiBaseUrl?: string; +} + +export interface ResolveGitlabConfigInput { + project?: GitlabConfigSettingsSource | ProjectSettings | null; + global?: GitlabConfigSettingsSource | GlobalSettings | null; +} + +export interface ResolvedGitlabConfig { + instanceUrl: string; + apiBaseUrl: string; +} + +function readConfiguredString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeHttpUrl(value: string, label: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a valid absolute http(s) URL`); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${label} must use http:// or https://`); + } + if (parsed.username || parsed.password) { + throw new Error(`${label} must not include username or password userinfo`); + } + if (!parsed.hostname) { + throw new Error(`${label} must include a hostname`); + } + + parsed.hash = ""; + parsed.search = ""; + parsed.pathname = parsed.pathname.replace(/\/+$/u, "") || "/"; + const normalized = parsed.toString().replace(/\/$/u, ""); + return normalized; +} + +function deriveApiBaseUrl(instanceUrl: string): string { + const parsed = new URL(instanceUrl); + const basePath = parsed.pathname.replace(/\/+$/u, ""); + parsed.pathname = `${basePath}/api/v4`.replace(/\/+/gu, "/"); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/u, ""); +} + +/** + * FNXC:GitLabConfiguration 2026-07-02-00:00: + * FN-7422 only establishes typed GitLab.com and self-managed URL configuration for later GitLab auth/import/tracking subtasks. Normalize and validate here before any future network client consumes these settings, preserving self-managed path prefixes while rejecting non-http(s) URLs and userinfo-bearing URLs. + */ +export function resolveGitlabConfig(input: ResolveGitlabConfigInput = {}): ResolvedGitlabConfig { + const projectInstanceUrl = readConfiguredString(input.project?.gitlabInstanceUrl); + const globalInstanceUrl = readConfiguredString(input.global?.gitlabInstanceUrl); + const projectApiBaseUrl = readConfiguredString(input.project?.gitlabApiBaseUrl); + const globalApiBaseUrl = readConfiguredString(input.global?.gitlabApiBaseUrl); + + const instanceUrl = normalizeHttpUrl(projectInstanceUrl ?? globalInstanceUrl ?? DEFAULT_GITLAB_INSTANCE_URL, "GitLab instance URL"); + const apiBaseUrl = projectApiBaseUrl ?? globalApiBaseUrl + ? normalizeHttpUrl(projectApiBaseUrl ?? globalApiBaseUrl ?? "", "GitLab API base URL") + : instanceUrl === DEFAULT_GITLAB_INSTANCE_URL + ? DEFAULT_GITLAB_API_BASE_URL + : deriveApiBaseUrl(instanceUrl); + + return { instanceUrl, apiBaseUrl }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb02e4f73d..e25ef0f07a 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, 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 } from "./types.js"; +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 } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index cebb36cda4..02d26f5df5 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -136,6 +136,8 @@ export const DEFAULT_GLOBAL_SETTINGS = { updateCheckFrequency: "daily", autoReloadOnVersionChange: true, githubTrackingDefaultRepo: undefined, + gitlabInstanceUrl: undefined, + gitlabApiBaseUrl: undefined, modelOnboardingComplete: undefined, useClaudeCli: undefined, useDroidCli: undefined, @@ -497,6 +499,8 @@ export const DEFAULT_PROJECT_SETTINGS = { githubTrackingEnabledByDefault: false, githubLinkImportedIssuesToTracking: false, githubTrackingDefaultRepo: undefined, + gitlabInstanceUrl: undefined, + gitlabApiBaseUrl: undefined, githubTrackingDedupEnabled: true, githubAuthMode: "gh-cli", githubAuthToken: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3042314192..75b2f43df5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,6 +15,12 @@ export type { CapacityRiskSignal } from "./capacity.js"; // FNXC:McpConfig 2026-06-26-02:10: The dashboard Vite build aliases @fusion/core to this browser-safe module, so the pure MCP config helpers are re-exported here for Settings UI import/export, validation, and project-over-global resolution without pulling Node-only stores into the client bundle. export { exportMcpServersJson, importMcpServersJson, resolveEffectiveMcpServers } from "./mcp-config.js"; +export { + DEFAULT_GITLAB_API_BASE_URL, + DEFAULT_GITLAB_INSTANCE_URL, + resolveGitlabConfig, +} from "./gitlab-config.js"; +export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./gitlab-config.js"; export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./settings-validation.js"; /** @@ -3152,6 +3158,12 @@ export interface GlobalSettings { /** Global fallback GitHub tracking repo in `owner/repo` format (FN-3868). * Used when a project has no githubTrackingDefaultRepo. */ githubTrackingDefaultRepo?: string; + /** Global fallback GitLab web instance URL. Defaults effectively to https://gitlab.com when unset. + * Project gitlabInstanceUrl overrides this value. */ + gitlabInstanceUrl?: string; + /** Global fallback GitLab REST API base URL. When unset, Fusion derives `/api/v4`. + * Project gitlabApiBaseUrl overrides this value. */ + gitlabApiBaseUrl?: string; /** Cadence for automatic update checks. The dashboard's `/update-check` * route uses this to decide whether to consult npm or return a cached * result. @@ -4264,6 +4276,14 @@ export interface ProjectSettings { /** Project default GitHub tracking repo in `owner/repo` format (FN-3868). * Falls back to global githubTrackingDefaultRepo when unset. */ githubTrackingDefaultRepo?: string; + /** + * FNXC:GitLabConfiguration 2026-07-02-00:00: + * FN-7422 adds only durable GitLab instance/API URL settings for GitLab.com and self-managed hosts. Later GitLab auth/import/tracking subtasks must consume the normalized resolver rather than adding tokens or network behavior here. + */ + /** Project GitLab web instance URL. Falls back to global gitlabInstanceUrl, then https://gitlab.com. */ + gitlabInstanceUrl?: string; + /** Project GitLab REST API base URL. Falls back to global gitlabApiBaseUrl, then derives `/api/v4`. */ + gitlabApiBaseUrl?: string; /** When true, tracking issue creation searches open/closed repo issues for likely duplicates before opening a new issue. * Default: true (set false to opt out). */ githubTrackingDedupEnabled?: boolean; diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index a1a6b4e045..3d636c897f 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -401,6 +401,59 @@ describe("splitSettingsSave", () => { expect(globalPatch).toEqual({}); }); + it("routes GitLab URL keys to the active settings scope", () => { + const payload: Record = { + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + }; + + const onGlobal = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "global-general", + }); + expect(onGlobal.globalPatch).toMatchObject(payload); + expect("gitlabInstanceUrl" in onGlobal.projectPatch).toBe(false); + expect("gitlabApiBaseUrl" in onGlobal.projectPatch).toBe(false); + + const onProject = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "general", + }); + expect("gitlabInstanceUrl" in onProject.globalPatch).toBe(false); + expect("gitlabApiBaseUrl" in onProject.globalPatch).toBe(false); + expect(onProject.projectPatch).toMatchObject(payload); + }); + + it("clears GitLab URL overrides with null in the active settings scope", () => { + const payload: Record = { gitlabInstanceUrl: undefined, gitlabApiBaseUrl: undefined }; + + const onGlobal = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { + global: { gitlabInstanceUrl: "https://global.example", gitlabApiBaseUrl: "https://global.example/api/v4" }, + project: {}, + } as never, + activeSection: "global-general", + }); + expect(onGlobal.globalPatch).toEqual({ gitlabInstanceUrl: null, gitlabApiBaseUrl: null }); + + const onProject = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { + global: {}, + project: { gitlabInstanceUrl: "https://project.example", gitlabApiBaseUrl: "https://project.example/api/v4" }, + } as never, + activeSection: "general", + }); + expect(onProject.projectPatch).toEqual({ gitlabInstanceUrl: null, gitlabApiBaseUrl: null }); + }); + it("routes githubTrackingDefaultRepo to global only on the global-general section", () => { const payloadGlobal: Record = { githubTrackingDefaultRepo: "org/repo" }; const onGlobal = splitSettingsSave({ diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 300164def2..76cfab8e69 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, type CSSProperties, type Mous import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon } from "lucide-react"; import { getErrorMessage, + resolveGitlabConfig, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, } from "@fusion/core"; @@ -2485,6 +2486,8 @@ export function SettingsModal({ maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(form), taskPrefix: form.taskPrefix?.trim() || undefined, githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined, + gitlabInstanceUrl: form.gitlabInstanceUrl?.trim() || undefined, + gitlabApiBaseUrl: form.gitlabApiBaseUrl?.trim() || undefined, githubAuthToken: form.githubAuthToken?.trim() || undefined, prTitlePromptInstructions: form.prTitlePromptInstructions?.trim() || undefined, prDescriptionPromptInstructions: form.prDescriptionPromptInstructions?.trim() || undefined, @@ -2495,6 +2498,23 @@ export function SettingsModal({ experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures), }; + if (activeSection === "general") { + resolveGitlabConfig({ + project: { + gitlabInstanceUrl: payload.gitlabInstanceUrl, + gitlabApiBaseUrl: payload.gitlabApiBaseUrl, + }, + }); + } + if (activeSection === "global-general") { + resolveGitlabConfig({ + global: { + gitlabInstanceUrl: payload.gitlabInstanceUrl, + gitlabApiBaseUrl: payload.gitlabApiBaseUrl, + }, + }); + } + // Always save both global and project settings with strict scope // separation. The split (global vs project routing, null-as-delete, and // changed-only project writes) lives in the pure `splitSettingsSave` diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index e5d78cb789..ddef913b3b 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -675,6 +675,31 @@ describe("SettingsModal", () => { } }); + it("saves GitLab URL configuration via global settings payload only", async () => { + renderModal({ initialSection: "global-general" }); + await waitForSettingsModalReady(); + + expect(screen.getByLabelText("Global GitLab instance URL")).toHaveAttribute("placeholder", "https://gitlab.com"); + expect(screen.getByText(/Blank defaults to GitLab.com/i)).toBeInTheDocument(); + + await settingsModalUser.type(screen.getByLabelText("Global GitLab instance URL"), " https://gitlab.company.test/ "); + await settingsModalUser.type(screen.getByLabelText("Global GitLab API base URL (optional / advanced)"), " https://gitlab.company.test/api/v4/ "); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalled(); + }); + + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect(globalPayload.gitlabInstanceUrl).toBe("https://gitlab.company.test/"); + expect(globalPayload.gitlabApiBaseUrl).toBe("https://gitlab.company.test/api/v4/"); + if (mockUpdateSettings.mock.calls.length > 0) { + const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; + expect(projectPayload.gitlabInstanceUrl).toBeUndefined(); + expect(projectPayload.gitlabApiBaseUrl).toBeUndefined(); + } + }); + it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => { mockFetchProjects.mockRejectedValueOnce(new Error("no projects")); @@ -968,6 +993,63 @@ describe("SettingsModal", () => { } }); + it("renders and saves GitLab URL configuration as project settings", async () => { + renderModal({ initialSection: "general" }); + await waitForSettingsModalReady(); + + expect(screen.getByRole("heading", { name: "GitLab Configuration" })).toBeInTheDocument(); + expect(screen.getByText(/Blank uses GitLab.com or the global default/i)).toBeInTheDocument(); + expect(screen.getByText(/Blank derives \/api\/v4/i)).toBeInTheDocument(); + + await settingsModalUser.type(screen.getByLabelText("GitLab instance URL"), " https://gitlab.example.com/gitlab/ "); + await settingsModalUser.type(screen.getByLabelText("GitLab API base URL (optional / advanced)"), " https://api.example.com/v4/ "); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalled(); + }); + + const payload = mockUpdateSettings.mock.calls[0][0] as Record; + expect(payload.gitlabInstanceUrl).toBe("https://gitlab.example.com/gitlab/"); + expect(payload.gitlabApiBaseUrl).toBe("https://api.example.com/v4/"); + if (mockUpdateGlobalSettings.mock.calls.length > 0) { + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect(globalPayload.gitlabInstanceUrl).toBeUndefined(); + expect(globalPayload.gitlabApiBaseUrl).toBeUndefined(); + } + }); + + it("clears GitLab URL project overrides back to defaults", async () => { + mockFetchSettings.mockResolvedValueOnce({ + ...defaultSettings, + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + }); + mockFetchSettingsByScope.mockResolvedValueOnce({ + global: defaultSettings, + project: { + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + }, + }); + + renderModal({ initialSection: "general" }); + await waitForSettingsModalReady(); + + await settingsModalUser.clear(screen.getByLabelText("GitLab instance URL")); + await settingsModalUser.clear(screen.getByLabelText("GitLab API base URL (optional / advanced)")); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalled(); + }); + + expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({ + gitlabInstanceUrl: null, + gitlabApiBaseUrl: null, + }); + }); + it("renders and saves imported GitHub issue tracking linking as a project setting", async () => { renderModal({ initialSection: "general" }); await waitForSettingsModalReady(); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index e711d00690..3fe9557182 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -73,6 +73,8 @@ const GLOBAL_SECTION_KEYS: Record> = { experimental: new Set(["experimentalFeatures"]), "global-general": new Set([ "githubTrackingDefaultRepo", + "gitlabInstanceUrl", + "gitlabApiBaseUrl", "language", "dismissModalsOnOutsideClick", "persistAgentToolOutput", @@ -319,6 +321,9 @@ export function splitSettingsSave({ if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { continue; } + if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl") && activeSection !== "global-general") { + continue; + } if (key === "mcpServers" && activeSection !== "global-mcp") { continue; } @@ -375,6 +380,7 @@ export function splitSettingsSave({ if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only if (key === "customProviders") continue; // persisted via dedicated routes, not save-split (see global branch above) if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; + if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl") && activeSection === "global-general") continue; if (key === "mcpServers" && activeSection === "global-mcp") continue; if (!isProjectSettingsKey(key)) continue; diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 0d33f66731..5bec8a76f5 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -281,6 +281,21 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))}/>{t("settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore", " Search the tracking repo for likely duplicates before opening a new issue ")} {t("settings.general.whenEnabledFusionChecksOpenAndClosedIssues", " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. ")} +

{t("settings.general.gitLabConfiguration", "GitLab Configuration")}

+ {/* + FNXC:GitLabConfiguration 2026-07-02-00:00: + FN-7422 exposes only project GitLab web/API URL configuration for GitLab.com and self-managed instances. Token auth, imports, tracking, comments, auto-close, Command Center signals, research providers, and star prompts are intentionally deferred to later GitLab subtasks. + */} +
+ + setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/> + {t("settings.general.gitLabInstanceUrlHint", "Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab.")} +
+
+ + setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/> + {t("settings.general.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")} +
); } export default GeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 83ca337e98..1a985c0a5a 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -20,6 +20,20 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/> {t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")} + {/* + FNXC:GitLabConfiguration 2026-07-02-00:00: + Global GitLab URL settings are fallbacks for projects that do not set their own self-managed GitLab instance/API URLs. FN-7422 keeps this to configuration only; auth, import, tracking, comments, and auto-close remain deferred. + */} +
+ + setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/> + {t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value.")} +
+
+ + setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/> + {t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL.")} +