From fafe4c9d9ff9c259041755b0cf540d87f4ba31de Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 09:27:39 -0700 Subject: [PATCH] FN-7423: add GitLab access token settings Add GitLab access-token configuration and auth resolution for future HTTP API integrations. - Add personal, project, and group GitLab token settings at project and global scope. - Resolve configured tokens with GITLAB_TOKEN fallback and PRIVATE-TOKEN metadata without requiring glab. - Document required GitLab scopes and update parity handoff notes. - Cover settings save splitting, parity, modal rendering, and auth resolution with tests. Files changed: .changeset/fn-7423-gitlab-access-tokens.md | 7 ++ docs/gitlab-parity-inventory.md | 10 +- docs/settings-reference.md | 6 +- docs/task-management.md | 2 +- .../core/src/__tests__/settings-parity.test.ts | 28 +++-- packages/core/src/index.ts | 2 +- packages/core/src/settings-schema.ts | 4 + packages/core/src/types.ts | 17 ++- .../app/__tests__/settings-save-split.test.ts | 69 ++++++++++++ .../dashboard/app/components/SettingsModal.tsx | 2 + .../SettingsModal.scheduling-merge.test.tsx | 57 ++++++++++ .../app/components/settings/save-split.ts | 6 +- .../settings/sections/GlobalGeneralSection.tsx | 18 ++- .../components/settings/sections/MergeSection.tsx | 22 ++++ .../dashboard/src/__tests__/gitlab-auth.test.ts | 99 +++++++++++++++++ packages/dashboard/src/gitlab-auth.ts | 123 +++++++++++++++++++++ 16 files changed, 449 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7423 Fusion-Task-Lineage: f14ae52b-b730-44b0-8bad-54945c61d009 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7423-gitlab-access-tokens.md | 7 + docs/gitlab-parity-inventory.md | 10 +- docs/settings-reference.md | 6 +- docs/task-management.md | 2 +- .../src/__tests__/settings-parity.test.ts | 28 ++-- packages/core/src/index.ts | 2 +- packages/core/src/settings-schema.ts | 4 + packages/core/src/types.ts | 17 ++- .../app/__tests__/settings-save-split.test.ts | 69 ++++++++++ .../app/components/SettingsModal.tsx | 2 + .../SettingsModal.scheduling-merge.test.tsx | 57 ++++++++ .../app/components/settings/save-split.ts | 6 +- .../sections/GlobalGeneralSection.tsx | 18 ++- .../settings/sections/MergeSection.tsx | 22 ++++ .../src/__tests__/gitlab-auth.test.ts | 99 ++++++++++++++ packages/dashboard/src/gitlab-auth.ts | 123 ++++++++++++++++++ 16 files changed, 449 insertions(+), 23 deletions(-) create mode 100644 .changeset/fn-7423-gitlab-access-tokens.md create mode 100644 packages/dashboard/src/__tests__/gitlab-auth.test.ts create mode 100644 packages/dashboard/src/gitlab-auth.ts diff --git a/.changeset/fn-7423-gitlab-access-tokens.md b/.changeset/fn-7423-gitlab-access-tokens.md new file mode 100644 index 0000000000..63d6d46341 --- /dev/null +++ b/.changeset/fn-7423-gitlab-access-tokens.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add GitLab access-token settings for personal, project, and group tokens. +category: feature +dev: Documents required GitLab API scopes and adds token auth resolution for later GitLab integration tasks. diff --git a/docs/gitlab-parity-inventory.md b/docs/gitlab-parity-inventory.md index 1f48218429..5c04e00302 100644 --- a/docs/gitlab-parity-inventory.md +++ b/docs/gitlab-parity-inventory.md @@ -47,8 +47,8 @@ Required included surfaces: issue import, linked issue tracking, completion comm | Webhooks for linked state | `POST /api/github/webhooks` verifies GitHub App signatures and updates issue/PR/task badges from pull_request/issues/issue_comment events. | `github-webhooks.ts`; `register-git-github.ts` | GitLab webhook support should use GitLab project/group/system hooks as provider-specific signed signal ingestion. Badge/state update hooks for GitLab-linked issues/MRs are separate from Command Center Signals but can reuse signature-validation patterns. Document exact GitLab headers/secrets in the implementation task. | | Batch status refresh and stale linked state | `POST /api/github/batch/status` refreshes issue/PR/tracking statuses. Cards render persisted state and stale badges without fetching on board load. | `register-git-github.ts`; `TaskDetailModal.tsx`; `ListView.tsx` | Add GitLab batch status refresh for linked GitLab issues/MRs/tracking issues. Board/list cards should render persisted provider-specific state and expose stale/unavailable status without blocking on network. | | Task detail/list affordances | Task Detail shows imported-source links, GitHub tracking summary, enable/create/unlink/delete controls, repo override, and list/board chips; ListView/cards show imported and tracking badges without duplicate chips when source equals tracking. | `TaskDetailModal.tsx`; `ListView.tsx`; dashboard CSS/tests | Add provider-aware linked issue/tracking UI. GitLab issue/MR chips must show instance/project context when needed to disambiguate self-managed hosts and group/subgroup paths. Avoid empty button shells on mobile. | -| Settings/auth keys | GitHub uses project/global `githubTrackingDefaultRepo`, project `githubTrackingEnabledByDefault`, `githubLinkImportedIssuesToTracking`, `githubTrackingDedupEnabled`, `githubAuthMode` (`gh-cli` or `token`), `githubAuthToken`, `githubCommentOnDone`, `githubCommentTemplate`, and `githubCloseSourceIssueOnDone`. | `ProjectSettings`; `GlobalSettings`; `settings-reference.md`; `GeneralSection.tsx`; `SettingsModal.tsx` | Add GitLab settings in a later task with provider-specific names. Required concepts: instance web/base URL, base REST API URL or derivation, token auth only, token kind label (personal/project/group) for operator clarity, token secret storage, tracking default project, tracking enabled default, imported issue adoption, comment-on-done/template, close-source-issue-on-done, dedup/search behavior, and self-managed readiness copy. | -| GitHub auth mode | GitHub tracking can use `gh-cli` or token; imports often require `gh` auth. Token fallback checks settings and `GITHUB_TOKEN`. | `github-auth.ts`; `github.ts`; CLI import code | GitLab parity must not introduce a required `glab` dependency. Use token auth only. Consider `GITLAB_TOKEN` as an environment fallback only if product/security review accepts it; settings/secrets storage should be the primary path. | +| Settings/auth keys | GitHub uses project/global `githubTrackingDefaultRepo`, project `githubTrackingEnabledByDefault`, `githubLinkImportedIssuesToTracking`, `githubTrackingDedupEnabled`, `githubAuthMode` (`gh-cli` or `token`), `githubAuthToken`, `githubCommentOnDone`, `githubCommentTemplate`, and `githubCloseSourceIssueOnDone`. | `ProjectSettings`; `GlobalSettings`; `settings-reference.md`; `GeneralSection.tsx`; `SettingsModal.tsx` | GitLab now has dual-scoped instance/API URL settings (`gitlabInstanceUrl`, `gitlabApiBaseUrl`) and access-token settings (`gitlabAuthToken`, `gitlabAuthTokenType`) for personal/project/group tokens. Remaining later settings: tracking default project, tracking enabled default, imported issue adoption, comment-on-done/template, close-source-issue-on-done, dedup/search behavior, and runtime self-managed readiness copy. | +| GitHub auth mode | GitHub tracking can use `gh-cli` or token; imports often require `gh` auth. Token fallback checks settings and `GITHUB_TOKEN`. | `github-auth.ts`; `github.ts`; CLI import code | GitLab parity must not introduce a required `glab` dependency. Use HTTP token auth only: configured project token → global fallback token → `GITLAB_TOKEN`, sent via GitLab REST `PRIVATE-TOKEN`. Supported token types are personal, project, and group; read-only API operations need `read_api` or `api`, while write actions such as notes/comments and close/reopen need `api` plus resource/role membership. | | Dashboard import navigation | Import Tasks is a lazy-loaded dashboard view/modal, reachable on desktop/tablet and mobile, with embedded main-content presentation and modal presentation. | `App.tsx`; `AppModals.tsx`; `GitHubImportModal.tsx`; `dashboard-guide.md` | GitLab import must be discoverable in the same Import Tasks surface on desktop and mobile, either as provider tabs or a provider selector. Do not add a separate top-level surface unless a later UX task revises navigation. | | Command Center GitHub analytics | `/api/command-center/github` and `GithubArea` aggregate local task-store data only: filed by Fusion from `githubTracking.issue`, fixed by Fusion from `sourceIssueProvider = "github"` done tasks, resolved rows, by-repo, daily, CSV export, and manual exact-close backfill. It never calls GitHub or `gh` during analytics rendering. | `github-issue-analytics.ts`; `register-command-center-routes.ts`; `GithubArea.tsx`; `dashboard-guide.md` | Add Command Center **GitLab** analytics as local-task-store-only. It should mirror filed/fixed/net/daily/by-project/resolved rows and CSV export for GitLab issues/MRs from persisted task metadata. Analytics must not call GitLab during rendering; exact timestamp backfill must be a manual operator action. | | Command Center Signals | `/api/command-center/signals`, `/api/command-center/signals/connectors`, and `POST /api/signals/:provider` ingest signed Sentry/Datadog/PagerDuty/generic webhook signals into local incidents. | `signals-connectors.md`; `SignalsArea.tsx`; signal routes | GitLab webhook/system-hook signals should map through the existing signed Signals connector model where applicable. Add a `gitlab` provider only after defining HMAC/token verification, event normalization, grouping keys, open/resolved mapping, and connector status without exposing secrets. | @@ -97,9 +97,9 @@ Merge request review-task metadata should use `mergeRequestId` and `mergeRequest ## Downstream handoff: FN-7422 through FN-7428 -- **FN-7422 — GitLab core types/settings/auth contract:** implement the hosting/auth/settings rows above: instance/base URL, API URL, token-kind labeling, token storage, token validation, provider-specific source metadata, and settings docs. Preserve GitHub settings untouched. -- **FN-7423 — GitLab HTTP client and import routes:** implement dashboard/API issue and merge-request fetch/detail/import rows. Support GitLab.com and self-managed instances, project and group issue discovery, project issue comments, and MR detail previews. -- **FN-7424 — GitLab tracking lifecycle:** implement provider-specific tracking issue creation/adoption, post-create hooks, Task Detail/List chips and controls, lifecycle notes, close/reopen/delete/unlink behavior, stale state, and batch status refresh. +- **FN-7422 — GitLab core URL configuration:** implemented GitLab.com/self-managed instance and API URL settings/resolution. Preserve GitHub settings untouched. +- **FN-7423 — GitLab access-token settings/auth contract:** implemented personal/project/group token settings, `GITLAB_TOKEN` fallback, `PRIVATE-TOKEN` auth metadata, and required scope documentation. Preserve GitHub settings untouched and do not add `glab`. +- **FN-7424 — GitLab HTTP client and import routes:** implement dashboard/API issue and merge-request fetch/detail/import rows. Support GitLab.com and self-managed instances, project and group issue discovery, project issue comments, and MR detail previews. - **FN-7425 — CLI and extension tools:** implement GitLab CLI import/browse commands and `fn_task_import_gitlab*` tools with permission classification matching GitHub import tools. Use HTTP token auth, not `glab`. - **FN-7426 — Completion comments and auto-close:** implement `gitlabCommentOnDone`, `gitlabCommentTemplate`, `gitlabCloseSourceIssueOnDone`, source issue reconciliation, and exact closed-at backfill for project issues. Keep MR close/merge behavior out unless explicitly scoped. - **FN-7427 — Command Center GitLab analytics and signals:** add local-task-store-only GitLab analytics with CSV export and optional manual exact-time backfill. Add GitLab webhook/system-hook Signals connector only after the signed verification and normalization contract is defined. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a878860368..e21d2b2014 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -93,6 +93,8 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `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. | +| `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. | +| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. | | `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. | @@ -590,11 +592,13 @@ Default notes: | `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. | +| `gitlabAuthToken` | `string` | `undefined` | Project GitLab access token for later GitLab import/tracking/comment/close HTTP API tasks. Project value takes precedence over global `gitlabAuthToken`, then process `GITLAB_TOKEN`. The dashboard renders the field as a password input and trims values on save; blank clears the project override. Dashboard location: **Settings → Project → Merge → GitLab Authentication**. | +| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Project GitLab token family label. Supported values are personal access token, project access token, and group access token. Select the token family that matches the credential; unsupported values fail auth resolution with `invalid_token_type`. | | `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). | -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). +GitLab configuration examples: leave both URL 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 URL fields when a self-managed API gateway differs from the web URL. GitLab auth uses access tokens over the GitLab REST API `PRIVATE-TOKEN` header; Fusion does not require or invoke `glab`. Supported token families are [personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/), [project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/), and [group access tokens](https://docs.gitlab.com/user/group/settings/group_access_tokens/). For later read-only GitLab issue/MR import and tracking reads, tokens need `read_api` or `api`; for later write-capable actions such as posting notes/comments and closing issues/MRs, tokens need `api`. Project and group access tokens are constrained to their associated resource and role membership, so the configured token must cover the target project or group. See GitLab REST authentication docs: . GitLab issue/MR 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 a43fef598e..e066cdbb9b 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -674,7 +674,7 @@ Recovery/backfill guidance: ## GitHub Issue Import and PR Creation -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. +GitLab instance/API URL and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for later GitLab HTTP API tasks; read-only operations require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. Runtime GitLab issue/MR import, tracking, comments, auto-close, Command Center signals, research/search support, and star-prompt behavior remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). Import issues: diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 3c7f1008e4..89b7c268d2 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -81,19 +81,21 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("agentMemoryInclusionMode")).toBe(false); }); - it("keeps GitLab URL configuration dual-scoped with blank defaults", () => { + it("keeps GitLab URL and token configuration dual-scoped with blank defaults", () => { expect(DEFAULT_GLOBAL_SETTINGS.gitlabInstanceUrl).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.gitlabApiBaseUrl).toBeUndefined(); + expect(DEFAULT_GLOBAL_SETTINGS.gitlabAuthToken).toBeUndefined(); + expect(DEFAULT_GLOBAL_SETTINGS.gitlabAuthTokenType).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"); + expect(DEFAULT_PROJECT_SETTINGS.gitlabAuthToken).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.gitlabAuthTokenType).toBeUndefined(); + for (const key of ["gitlabInstanceUrl", "gitlabApiBaseUrl", "gitlabAuthToken", "gitlabAuthTokenType"] as const) { + expect(isGlobalSettingsKey(key)).toBe(true); + expect(isProjectSettingsKey(key)).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain(key); + expect(GLOBAL_SETTINGS_KEYS).toContain(key); + } }); it("defaults persisted thinking logs to disabled", () => { @@ -421,9 +423,11 @@ 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. GitLab URL settings are also dual-scoped (FN-7422) + // tailors the MCP server set per project. GitLab URL settings are 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. + // can override hosts or API prefixes; GitLab token settings are dual-scoped (FN-7423) + // so projects can override global fallback credentials without treating project/group tokens + // as globally authorized. Keep this allow-list in GLOBAL_SETTINGS_KEYS order. expect(overlap).toEqual([ "testMode", "mergeRequestContractShadowEnabled", @@ -431,6 +435,8 @@ describe("settings key parity", () => { "githubTrackingDefaultRepo", "gitlabInstanceUrl", "gitlabApiBaseUrl", + "gitlabAuthToken", + "gitlabAuthTokenType", "mcpServers", "worktrunk", "owningNodeHandoffPolicy", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e25ef0f07a..31c16fab82 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 } 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 { 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 02d26f5df5..aa85f80fe5 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -138,6 +138,8 @@ export const DEFAULT_GLOBAL_SETTINGS = { githubTrackingDefaultRepo: undefined, gitlabInstanceUrl: undefined, gitlabApiBaseUrl: undefined, + gitlabAuthToken: undefined, + gitlabAuthTokenType: undefined, modelOnboardingComplete: undefined, useClaudeCli: undefined, useDroidCli: undefined, @@ -501,6 +503,8 @@ export const DEFAULT_PROJECT_SETTINGS = { githubTrackingDefaultRepo: undefined, gitlabInstanceUrl: undefined, gitlabApiBaseUrl: undefined, + gitlabAuthToken: undefined, + gitlabAuthTokenType: undefined, githubTrackingDedupEnabled: true, githubAuthMode: "gh-cli", githubAuthToken: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 75b2f43df5..4e502f4a24 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3164,6 +3164,14 @@ export interface GlobalSettings { /** Global fallback GitLab REST API base URL. When unset, Fusion derives `/api/v4`. * Project gitlabApiBaseUrl overrides this value. */ gitlabApiBaseUrl?: string; + /** + * FNXC:GitLabAuthentication 2026-07-02-00:00: + * FN-7423 accepts personal, project, and group GitLab access tokens for later HTTP API import/tracking/comment/close tasks. Global values are fallbacks only; project settings override them and project/group token resource membership still constrains runtime access. + */ + /** Global fallback GitLab access token. Stored as a plain settings string in this phase; UI must render it only as a password field. */ + gitlabAuthToken?: string; + /** Global fallback GitLab token type label. Defaults effectively to "personal" when a token exists and this is unset. */ + gitlabAuthTokenType?: GitlabAuthTokenType; /** Cadence for automatic update checks. The dashboard's `/update-check` * route uses this to decide whether to consult npm or return a cached * result. @@ -3477,6 +3485,9 @@ export interface RemoteAccessProjectSettings { /** GitHub authentication strategy used by project issue-tracking settings (FN-3868). */ export type GithubAuthMode = "gh-cli" | "token"; +/** GitLab access-token family configured for future HTTP API integrations (FN-7423). */ +export type GitlabAuthTokenType = "personal" | "project" | "group"; + export interface SecretsEnvSettings { /** Default: false. When true, materialize env_exportable secrets into the worktree on creation. */ enabled?: boolean; @@ -4278,12 +4289,16 @@ export interface ProjectSettings { 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. + * FN-7422 adds durable GitLab instance/API URL settings for GitLab.com and self-managed hosts. FN-7423 layers token settings onto the same project-over-global configuration contract without adding runtime GitLab imports or tracking. */ /** 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; + /** Project GitLab access token for HTTP API auth. Stored as a plain settings string in this phase; UI must render it only as a password field. */ + gitlabAuthToken?: string; + /** Project GitLab token type label. Defaults effectively to "personal" when a token exists and this is unset. */ + gitlabAuthTokenType?: GitlabAuthTokenType; /** 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 3d636c897f..a55bed4672 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -28,6 +28,10 @@ describe("scope anchors", () => { expect(isProjectSettingsKey("enabledBuiltinWorkflowIds")).toBe(true); expect(isProjectSettingsKey("githubLinkImportedIssuesToTracking")).toBe(true); expect(isGlobalSettingsKey("githubLinkImportedIssuesToTracking")).toBe(false); + expect(isGlobalSettingsKey("gitlabAuthToken")).toBe(true); + expect(isProjectSettingsKey("gitlabAuthToken")).toBe(true); + expect(isGlobalSettingsKey("gitlabAuthTokenType")).toBe(true); + expect(isProjectSettingsKey("gitlabAuthTokenType")).toBe(true); }); it("every MODEL_LANE_KEYS entry is a project settings key", () => { @@ -216,6 +220,71 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 7 }); }); + it("routes GitLab token settings to global settings only from global general", () => { + const initialScopedValues = { + global: { gitlabAuthToken: undefined, gitlabAuthTokenType: undefined }, + project: { gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }, + } as never; + + const payload: Record = { + gitlabAuthToken: "global-token", + gitlabAuthTokenType: "group", + }; + + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "global-general", + }); + + expect(globalPatch).toEqual({ gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }); + expect(projectPatch).toEqual({}); + }); + + it("routes GitLab token settings to project settings outside global general", () => { + const initialScopedValues = { + global: { gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }, + project: {}, + } as never; + + const payload: Record = { + gitlabAuthToken: "project-token", + gitlabAuthTokenType: "project", + }; + + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "merge", + }); + + expect(globalPatch).toEqual({}); + expect(projectPatch).toEqual({ gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }); + }); + + it("clears a project GitLab token with null-as-delete while preserving selected token type", () => { + const initialScopedValues = { + global: {}, + project: { gitlabAuthToken: "old-project-token", gitlabAuthTokenType: "group" }, + } as never; + + const payload: Record = { + gitlabAuthToken: undefined, + gitlabAuthTokenType: "personal", + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "merge", + }); + + expect(projectPatch).toEqual({ gitlabAuthToken: null, gitlabAuthTokenType: "personal" }); + }); + it("routes imported GitHub issue linking only to project settings", () => { const initialScopedValues = { global: {}, diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 76cfab8e69..e9d62c04f8 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2488,6 +2488,8 @@ export function SettingsModal({ githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined, gitlabInstanceUrl: form.gitlabInstanceUrl?.trim() || undefined, gitlabApiBaseUrl: form.gitlabApiBaseUrl?.trim() || undefined, + gitlabAuthToken: form.gitlabAuthToken?.trim() || undefined, + gitlabAuthTokenType: form.gitlabAuthTokenType ?? "personal", githubAuthToken: form.githubAuthToken?.trim() || undefined, prTitlePromptInstructions: form.prTitlePromptInstructions?.trim() || undefined, prDescriptionPromptInstructions: form.prDescriptionPromptInstructions?.trim() || undefined, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 97ed866c14..d7797ef43b 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -1337,6 +1337,63 @@ describe("SettingsModal", () => { expect(payload.githubAuthMode).toBe("token"); expect(payload.githubAuthToken).toBe("ghp_test_token"); }); + + it("renders GitLab authentication controls as secret-safe project settings", async () => { + renderModal({ initialSection: "merge" }); + await waitForSettingsModalReady(); + + expect(screen.getByRole("heading", { name: "GitLab Authentication" })).toBeInTheDocument(); + const tokenInput = screen.getByLabelText("GitLab access token") as HTMLInputElement; + expect(tokenInput.type).toBe("password"); + expect(screen.getByText(/Read-only GitLab operations need read_api or api/i)).toBeInTheDocument(); + expect(screen.queryByText("glpat_test_token")).not.toBeInTheDocument(); + expect((screen.getByLabelText("GitLab token type") as HTMLSelectElement).value).toBe("personal"); + }); + + it.each(["personal", "project", "group"] as const)("saves a %s GitLab access token from Merge", async (tokenType) => { + renderModal({ initialSection: "merge" }); + await waitForSettingsModalReady(); + + await settingsModalUser.selectOptions(screen.getByLabelText("GitLab token type"), tokenType); + await settingsModalUser.type(screen.getByLabelText("GitLab access token"), " glpat_test_token "); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalled(); + }); + + const payload = mockUpdateSettings.mock.calls[0][0] as Record; + expect(payload.gitlabAuthTokenType).toBe(tokenType); + expect(payload.gitlabAuthToken).toBe("glpat_test_token"); + expect(mockUpdateGlobalSettings).not.toHaveBeenCalledWith(expect.objectContaining({ gitlabAuthToken: expect.anything() })); + }); + + it("clears a saved project GitLab access token without clearing the selected type", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + gitlabAuthToken: "saved-token", + gitlabAuthTokenType: "group", + }); + mockFetchSettingsByScope.mockResolvedValue({ + global: defaultSettings, + project: { gitlabAuthToken: "saved-token", gitlabAuthTokenType: "group" }, + }); + + renderModal({ initialSection: "merge" }); + await waitForSettingsModalReady(); + + await settingsModalUser.clear(screen.getByLabelText("GitLab access token")); + await settingsModalUser.selectOptions(screen.getByLabelText("GitLab token type"), "project"); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalled(); + }); + + const payload = mockUpdateSettings.mock.calls[0][0] as Record; + expect(payload.gitlabAuthToken).toBeNull(); + expect(payload.gitlabAuthTokenType).toBe("project"); + }); }); describe("Experimental Features section", () => { diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 3fe9557182..7b30f177e9 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -75,6 +75,8 @@ const GLOBAL_SECTION_KEYS: Record> = { "githubTrackingDefaultRepo", "gitlabInstanceUrl", "gitlabApiBaseUrl", + "gitlabAuthToken", + "gitlabAuthTokenType", "language", "dismissModalsOnOutsideClick", "persistAgentToolOutput", @@ -321,7 +323,7 @@ export function splitSettingsSave({ if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { continue; } - if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl") && activeSection !== "global-general") { + if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "global-general") { continue; } if (key === "mcpServers" && activeSection !== "global-mcp") { @@ -380,7 +382,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 === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "global-general") continue; if (key === "mcpServers" && activeSection === "global-mcp") continue; if (!isProjectSettingsKey(key)) continue; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 1a985c0a5a..d5b8418a45 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -22,7 +22,10 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin {/* 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. + Global GitLab URL settings are fallbacks for projects that do not set their own self-managed GitLab instance/API URLs. + + FNXC:GitLabAuthentication 2026-07-02-00:00: + Global GitLab token settings are secret-safe fallbacks for projects without their own token override. They do not make project/group access tokens globally authorized; resource membership still applies to future GitLab runtime tasks. */}
@@ -34,6 +37,19 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin 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.")}
+
+ + +
+
+ + setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/> + {t("settings.globalGeneral.gitLabAuthTokenHint", "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; future write actions need api; project/group tokens remain limited by resource membership.")} +
)} +

{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}

+ {/** + * FNXC:GitLabAuthentication 2026-07-02-00:00: + * FN-7423 exposes project GitLab token configuration only as secret-safe password input plus a personal/project/group token-type label. Later GitLab import/tracking/comment/close runtime tasks consume PRIVATE-TOKEN auth and must enforce read_api/api scope requirements documented here and in user docs. + */} +
+ + +
+
+ + setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/> + {t("settings.merge.gitLabAuthTokenHint", "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership.")} +
+
+ {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.gitLabAuthDetails", "Fusion uses GitLab REST API token authentication with the PRIVATE-TOKEN header. Leave the token blank to clear the project override and fall back to a configured global GitLab token or GITLAB_TOKEN where available.")} +
diff --git a/packages/dashboard/src/__tests__/gitlab-auth.test.ts b/packages/dashboard/src/__tests__/gitlab-auth.test.ts new file mode 100644 index 0000000000..ecb68e81ae --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-auth.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { resolveGitlabAuth } from "../gitlab-auth.js"; + +/* +FNXC:GitLabAuthentication 2026-07-02-00:00: +These tests pin FN-7423's credential contract for later GitLab runtime tasks: configured tokens beat fallbacks, all supported token families resolve through PRIVATE-TOKEN, unset type defaults to personal, and unsupported token types fail instead of silently downgrading. +*/ +describe("resolveGitlabAuth", () => { + it.each(["personal", "project", "group"] as const)("resolves %s access tokens with PRIVATE-TOKEN metadata", (tokenType) => { + expect( + resolveGitlabAuth({ + projectSettings: { + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabAuthToken: ` ${tokenType}_token `, + gitlabAuthTokenType: tokenType, + }, + env: {}, + }), + ).toEqual({ + ok: true, + auth: { + apiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + webBaseUrl: "https://gitlab.example.com/gitlab", + token: `${tokenType}_token`, + tokenType, + headerName: "PRIVATE-TOKEN", + }, + }); + }); + + it("defaults token type to personal when a token exists without an explicit type", () => { + expect(resolveGitlabAuth({ projectSettings: { gitlabAuthToken: "glpat_project" }, env: {} })).toMatchObject({ + ok: true, + auth: { tokenType: "personal", headerName: "PRIVATE-TOKEN" }, + }); + }); + + it("uses project token and project type before global fallback settings", () => { + expect( + resolveGitlabAuth({ + projectSettings: { gitlabAuthToken: " project-token ", gitlabAuthTokenType: "project" }, + globalSettings: { gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }, + env: { GITLAB_TOKEN: "env-token" }, + }), + ).toMatchObject({ ok: true, auth: { token: "project-token", tokenType: "project" } }); + }); + + it("uses global token fallback before GITLAB_TOKEN", () => { + expect( + resolveGitlabAuth({ + projectSettings: { gitlabAuthToken: " " }, + globalSettings: { gitlabAuthToken: " global-token ", gitlabAuthTokenType: "group" }, + env: { GITLAB_TOKEN: "env-token" }, + }), + ).toMatchObject({ ok: true, auth: { token: "global-token", tokenType: "group" } }); + }); + + it("uses GITLAB_TOKEN fallback without adding a GitLab CLI dependency", () => { + const resolution = resolveGitlabAuth({ projectSettings: {}, globalSettings: {}, env: { GITLAB_TOKEN: " env-token " } }); + expect(resolution).toMatchObject({ ok: true, auth: { token: "env-token", tokenType: "personal" } }); + expect(JSON.stringify(resolution)).not.toMatch(/glab|cli/i); + }); + + it("returns token_missing when configured settings and GITLAB_TOKEN are blank", () => { + expect( + resolveGitlabAuth({ + projectSettings: { gitlabAuthToken: " \t " }, + globalSettings: { gitlabAuthToken: "" }, + env: { GITLAB_TOKEN: " " }, + }), + ).toEqual({ + ok: false, + reason: "token_missing", + message: "GitLab auth requires gitlabAuthToken or GITLAB_TOKEN.", + }); + }); + + it("rejects unsupported token types", () => { + expect( + resolveGitlabAuth({ + projectSettings: { gitlabAuthToken: "token", gitlabAuthTokenType: "deploy" }, + env: {}, + }), + ).toEqual({ + ok: false, + reason: "invalid_token_type", + message: "Invalid gitlabAuthTokenType. Expected \"personal\", \"project\", or \"group\".", + }); + }); + + it("surfaces invalid GitLab URL configuration without network calls", () => { + expect( + resolveGitlabAuth({ + projectSettings: { gitlabInstanceUrl: "ftp://gitlab.example.com", gitlabAuthToken: "token" }, + env: {}, + }), + ).toMatchObject({ ok: false, reason: "invalid_config" }); + }); +}); diff --git a/packages/dashboard/src/gitlab-auth.ts b/packages/dashboard/src/gitlab-auth.ts new file mode 100644 index 0000000000..230b5678a6 --- /dev/null +++ b/packages/dashboard/src/gitlab-auth.ts @@ -0,0 +1,123 @@ +import { resolveGitlabConfig, type GlobalSettings, type ProjectSettings } from "@fusion/core"; +import type { GitlabAuthTokenType } from "@fusion/core"; + +export const GITLAB_AUTH_HEADER_NAME = "PRIVATE-TOKEN" as const; +export const GITLAB_AUTH_TOKEN_TYPES = ["personal", "project", "group"] as const satisfies readonly GitlabAuthTokenType[]; + +export interface GitlabAuthSettingsSource { + gitlabInstanceUrl?: string; + gitlabApiBaseUrl?: string; + gitlabAuthToken?: string; + gitlabAuthTokenType?: GitlabAuthTokenType | string; +} + +export interface ResolvedGitlabAuth { + apiBaseUrl: string; + webBaseUrl: string; + token: string; + tokenType: GitlabAuthTokenType; + headerName: typeof GITLAB_AUTH_HEADER_NAME; +} + +export type GitlabAuthResolution = + | { ok: true; auth: ResolvedGitlabAuth } + | { + ok: false; + reason: "token_missing" | "invalid_token_type" | "invalid_config"; + message: string; + }; + +export interface ResolveGitlabAuthDeps { + projectSettings?: GitlabAuthSettingsSource | Pick | null; + globalSettings?: GitlabAuthSettingsSource | Partial | Record | null; + env?: NodeJS.ProcessEnv; +} + +function pickString(source: Record | undefined | null, key: string): string | undefined { + const value = source?.[key]; + return typeof value === "string" ? value : undefined; +} + +function readConfiguredString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeTokenType(value: unknown): GitlabAuthTokenType | undefined | "invalid" { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") return "invalid"; + const trimmed = value.trim(); + if (trimmed === "") return undefined; + return (GITLAB_AUTH_TOKEN_TYPES as readonly string[]).includes(trimmed) ? trimmed as GitlabAuthTokenType : "invalid"; +} + +function firstConfiguredTokenType(...values: unknown[]): GitlabAuthTokenType | undefined | "invalid" { + for (const value of values) { + const normalized = normalizeTokenType(value); + if (normalized !== undefined) return normalized; + } + return undefined; +} + +/** + * FNXC:GitLabAuthentication 2026-07-02-00:00: + * FN-7423 resolves personal, project, and group GitLab access tokens for future HTTP API integrations without invoking `glab` or any GitLab CLI. GitLab REST auth uses the PRIVATE-TOKEN header; read-only features require read_api or api, while future write/comment/close features require api and token resource membership. + */ +export function resolveGitlabAuth(deps: ResolveGitlabAuthDeps = {}): GitlabAuthResolution { + let config: ReturnType; + try { + config = resolveGitlabConfig({ + project: deps.projectSettings ?? undefined, + global: deps.globalSettings as Partial | undefined, + }); + } catch (error) { + return { + ok: false, + reason: "invalid_config", + message: error instanceof Error ? error.message : "Invalid GitLab configuration.", + }; + } + + const project = deps.projectSettings as Record | null | undefined; + const global = deps.globalSettings as Record | null | undefined; + const env = deps.env ?? process.env; + const token = readConfiguredString(project?.gitlabAuthToken) + ?? readConfiguredString(global?.gitlabAuthToken) + ?? readConfiguredString(pickString(global, "projectGitlabAuthToken")) + ?? readConfiguredString(env.GITLAB_TOKEN) + ?? ""; + + const tokenType = firstConfiguredTokenType( + project?.gitlabAuthTokenType, + global?.gitlabAuthTokenType, + pickString(global, "projectGitlabAuthTokenType"), + ); + + if (tokenType === "invalid") { + return { + ok: false, + reason: "invalid_token_type", + message: "Invalid gitlabAuthTokenType. Expected \"personal\", \"project\", or \"group\".", + }; + } + + if (!token) { + return { + ok: false, + reason: "token_missing", + message: "GitLab auth requires gitlabAuthToken or GITLAB_TOKEN.", + }; + } + + return { + ok: true, + auth: { + apiBaseUrl: config.apiBaseUrl, + webBaseUrl: config.instanceUrl, + token, + tokenType: tokenType ?? "personal", + headerName: GITLAB_AUTH_HEADER_NAME, + }, + }; +}