From 509155ea528510a980c4a2b540adf4d810cd543f Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 13 Jul 2026 19:50:55 +0000 Subject: [PATCH 01/31] Improve source control provider setup guidance --- .../providers/source-control/bitbucket.mdx | 18 ++-- apps/docs/providers/source-control/gitea.mdx | 4 + apps/docs/providers/source-control/gitlab.mdx | 14 ++- .../setup/GitLabSourceControlConfig.tsx | 39 -------- .../StepSourceControlConfig.client.test.tsx | 93 ++++++++++++++++++- .../setup/StepSourceControlConfig.tsx | 36 ++----- .../setup/sourceControlSetupCopy.ts | 8 +- .../src/setup-source-control-config.test.ts | 19 ++++ .../types/src/setup-source-control-config.ts | 12 +++ 9 files changed, 159 insertions(+), 84 deletions(-) delete mode 100644 apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx diff --git a/apps/docs/providers/source-control/bitbucket.mdx b/apps/docs/providers/source-control/bitbucket.mdx index 30678a74..3f394309 100644 --- a/apps/docs/providers/source-control/bitbucket.mdx +++ b/apps/docs/providers/source-control/bitbucket.mdx @@ -22,13 +22,13 @@ the workspaces or repositories Roomote should use. In that account's choose **Create API token with scopes**, select **Bitbucket** as the app, and grant these scopes: -| Scope | Needed for | -| ----- | ---------- | -| read:repository:bitbucket and write:repository:bitbucket | cloning, branch creation, and commits | -| read:pullrequest:bitbucket and write:pullrequest:bitbucket | pull request workflows and comments | -| read:webhook:bitbucket and write:webhook:bitbucket | automatic webhook setup during repository sync | -| read:workspace:bitbucket | workspace discovery during repository sync | -| read:user:bitbucket | identity validation when saving credentials | +| Scope | Needed for | +| ---------------------------------------------------------- | ---------------------------------------------- | +| read:repository:bitbucket and write:repository:bitbucket | cloning, branch creation, and commits | +| read:pullrequest:bitbucket and write:pullrequest:bitbucket | pull request workflows and comments | +| read:webhook:bitbucket and write:webhook:bitbucket | automatic webhook setup during repository sync | +| read:workspace:bitbucket | workspace discovery during repository sync | +| read:user:bitbucket | identity validation when saving credentials | Save the token and the Atlassian account email in setup, deployment env vars, or local `.env.local`: @@ -52,6 +52,10 @@ git-over-HTTPS, Roomote automatically uses Bitbucket's static `BITBUCKET_BASE_URL` defaults to `https://bitbucket.org`. Only Bitbucket Cloud is accepted; other hosts are rejected. +The setup flow asks only for the API token and its Atlassian account email. +Roomote uses the Bitbucket Cloud URL and generates the webhook secret +automatically; optional OAuth settings remain available under Settings. + ## Sync repositories After the Bitbucket values are available, open Settings, go to the Environments diff --git a/apps/docs/providers/source-control/gitea.mdx b/apps/docs/providers/source-control/gitea.mdx index 913712ee..fd08be04 100644 --- a/apps/docs/providers/source-control/gitea.mdx +++ b/apps/docs/providers/source-control/gitea.mdx @@ -42,6 +42,10 @@ GITEA_WEBHOOK_SECRET= When `GITEA_USERNAME` is omitted, Roomote resolves the token owner through the Gitea API and uses that login for Git-over-HTTPS credentials. +The setup flow asks only for the Gitea instance URL and access token. Roomote +generates the webhook secret during repository sync; optional username and OAuth +settings remain available under Settings. + ## Sync repositories After the Gitea values are available, open Settings, go to the Environments diff --git a/apps/docs/providers/source-control/gitlab.mdx b/apps/docs/providers/source-control/gitlab.mdx index 92b425e8..bece2667 100644 --- a/apps/docs/providers/source-control/gitlab.mdx +++ b/apps/docs/providers/source-control/gitlab.mdx @@ -17,11 +17,11 @@ Create a dedicated GitLab bot or service-account user and add it to the groups or projects Roomote should access. Create an access token for that identity with scopes that match the behavior you want: -| Scope | Needed for | -| ----- | ---------- | -| `api` | project listing and repository sync through REST | -| `read_repository` | cloning private repositories over Git-over-HTTPS | -| `write_repository` | pushing branches or commits from Roomote tasks | +| Scope | Needed for | +| ------------------ | ------------------------------------------------ | +| `api` | project listing and repository sync through REST | +| `read_repository` | cloning private repositories over Git-over-HTTPS | +| `write_repository` | pushing branches or commits from Roomote tasks | Save the token in setup, deployment env vars, or local `.env.local`: @@ -31,6 +31,10 @@ GITLAB_TOKEN= For self-managed GitLab, also set `GITLAB_BASE_URL`. +The setup flow asks only for the access token and, when needed, the self-managed +GitLab base URL. OAuth and webhook verification settings remain available under +Settings for deployments that need them. + ## Sync repositories After the token is available, open Settings, go to the Environments page, and diff --git a/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx deleted file mode 100644 index d052767b..00000000 --- a/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Button, CopyIconButton, ExternalLink } from '@/components/system'; - -export function GitLabSourceControlConfig({ - applicationsUrl, - redirectUri, -}: { - applicationsUrl: string; - redirectUri: string; -}) { - return ( -
-
-

- Recommended: create a GitLab OAuth application. - -

-

- Teammates can trigger Roomote from merge request comments only after - linking their GitLab account, and that linking flow needs an OAuth - application. Create one on the bot account (or as a group or - instance-wide application), mark it confidential, select the{' '} - read_user scope, and use this redirect URI: -

-

- {redirectUri} - -

-

- Then paste the generated Application ID and Secret into the OAuth - fields below. -

-
-
- ); -} diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 74fff52d..21270176 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -127,6 +127,35 @@ function buildSourceControlSetup( }; } +function buildCatalogProviderSetup( + provider: 'gitlab' | 'gitea' | 'bitbucket', +): SetupSourceControlStatus { + const descriptor = SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.find( + (candidate) => candidate.provider === provider, + )!; + + return buildSourceControlSetup({ + preselectedProvider: provider, + providers: [ + { + ...descriptor, + runtimeConfigSatisfied: false, + savedConfigSatisfied: false, + configSatisfied: false, + configSatisfiedByRuntimeEnv: false, + connected: false, + repositoryCount: 0, + fields: descriptor.fields.map((field) => ({ + ...field, + runtimeSatisfied: false, + savedSatisfied: false, + satisfiedByEnvVarName: null, + })), + }, + ], + }); +} + describe('StepSourceControlConfig', () => { beforeEach(() => { vi.clearAllMocks(); @@ -210,10 +239,10 @@ describe('StepSourceControlConfig', () => { ).toBeInTheDocument(); }); - it('keeps token-backed providers on the existing config form', () => { + it('guides GitLab token creation without showing OAuth configuration', () => { render( , @@ -222,11 +251,71 @@ describe('StepSourceControlConfig', () => { expect( screen.getByText('GitLab Personal Access Token'), ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Open/ })).toHaveAttribute( + 'href', + 'https://gitlab.com/-/user_settings/personal_access_tokens', + ); + expect( + screen.getByText(/avatar → Edit profile → Access tokens/), + ).toBeInTheDocument(); + expect( + screen.queryByText(/GitLab OAuth Client ID/), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Create GitHub App' }), ).not.toBeInTheDocument(); }); + it('guides Gitea token creation without optional credentials', () => { + render( + , + ); + + expect(screen.getByText('Gitea Base URL')).toBeInTheDocument(); + expect(screen.getByText('Gitea Access Token')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /View Gitea guide/ }), + ).toHaveAttribute('href', 'https://docs.gitea.com/development/api-usage'); + expect( + screen.getByText(/Settings → Applications → Manage Access Tokens/), + ).toBeInTheDocument(); + expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); + expect(screen.queryByText('Gitea OAuth Client ID')).not.toBeInTheDocument(); + expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); + }); + + it('guides Bitbucket token creation without optional credentials', () => { + render( + , + ); + + expect(screen.getByText('Bitbucket API Token')).toBeInTheDocument(); + expect(screen.getByText('Atlassian Account Email')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Open/ })).toHaveAttribute( + 'href', + 'https://id.atlassian.com/manage-profile/security/api-tokens', + ); + expect( + screen.getByText(/Create API token with scopes → Bitbucket/), + ).toBeInTheDocument(); + expect(screen.queryByText('Bitbucket Base URL')).not.toBeInTheDocument(); + expect( + screen.queryByText('Bitbucket OAuth Client ID'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Bitbucket Webhook Secret'), + ).not.toBeInTheDocument(); + }); + function buildAdoSourceControlSetup( fieldOverrides: Partial< SetupSourceControlStatus['providers'][number]['fields'][number] diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 60bb6f18..467c8fa3 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import Link from 'next/link'; import { toast } from 'sonner'; import { getSetupSourceControlVisibleFields, @@ -29,7 +28,6 @@ import { DEFAULT_ADO_AUTH_MODE, } from './AdoSourceControlConfig'; import { GitHubSourceControlConfig } from './GitHubSourceControlConfig'; -import { GitLabSourceControlConfig } from './GitLabSourceControlConfig'; import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; @@ -281,12 +279,10 @@ export function StepSourceControlConfig({ ? getSourceControlSetupCopy(selectedProvider.provider) : null; const providerSetupLabel = providerSetupCopy?.setupLabel ?? 'source control'; - const isGitLab = selectedProvider?.provider === 'gitlab'; const publicOrigin = typeof window === 'undefined' ? 'https://your-deployment-url' : window.location.origin; - const gitlabRedirectUri = `${publicOrigin}/api/auth/oauth2/callback/gitlab`; const typedGitLabBaseUrl = values['GITLAB_BASE_URL']?.trim().replace(/\/+$/, '') ?? ''; const configuredGitLabBaseUrl = @@ -296,8 +292,10 @@ export function StepSourceControlConfig({ : /^https?:\/\//.test(configuredGitLabBaseUrl) ? configuredGitLabBaseUrl : 'https://gitlab.com'; - const gitlabApplicationsUrl = `${effectiveGitLabBaseUrl}/-/user_settings/applications`; - const valuesStepNumber = isGitLab ? 3 : 2; + const creationHref = + selectedProvider?.provider === 'gitlab' + ? `${effectiveGitLabBaseUrl}/-/user_settings/personal_access_tokens` + : providerSetupCopy?.creationHref; if (selectedProvider?.provider === 'github' && !showManualGitHubValues) { return ( @@ -323,11 +321,12 @@ export function StepSourceControlConfig({ Create a new {providerSetupCopy.setupLabel}. @@ -340,16 +339,6 @@ export function StepSourceControlConfig({ {providerSetupCopy.creationHint}

) : null} -

- If you need it,{' '} - - here's our logo - - . -

) : ( @@ -372,16 +361,7 @@ export function StepSourceControlConfig({ ) : null} - {isGitLab ? ( - - - - ) : null} - - +

Enter the values below for your {provider ?? 'source control'}{' '} integration. diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index c1a31f0c..add101d2 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -2,6 +2,7 @@ import type { SourceControlProvider } from '@roomote/types'; type SourceControlSetupCopy = { creationHref: string; + creationLinkLabel?: string; setupLabel: string; /** Indefinite article for `setupLabel` ("a" unless the label needs "an"). */ setupLabelArticle?: 'a' | 'an'; @@ -20,19 +21,20 @@ const SOURCE_CONTROL_SETUP_COPY: Record< creationHref: 'https://gitlab.com/-/user_settings/personal_access_tokens', setupLabel: 'GitLab access token', creationHint: - 'Create the token with the api scope. Prefer a bot or service account that is a member of the groups Roomote should access; Roomote syncs its projects and configures merge request webhooks automatically.', + 'In GitLab, go to your avatar → Edit profile → Access tokens → Add new token. Select the api scope. Use a bot or service account that belongs to the groups Roomote should access and can manage project webhooks.', }, gitea: { creationHref: 'https://docs.gitea.com/development/api-usage', + creationLinkLabel: 'View Gitea guide', setupLabel: 'Gitea access token', creationHint: - 'Create the token with repository access on the instance Roomote should use. Prefer a bot or service account that can administer repository webhooks; Roomote syncs repositories and configures pull request webhooks automatically.', + 'In your Gitea instance, go to your avatar → Settings → Applications → Manage Access Tokens → Generate Token. Use a bot or service account with read and write access to the repositories Roomote should use and permission to manage their webhooks.', }, bitbucket: { creationHref: 'https://id.atlassian.com/manage-profile/security/api-tokens', setupLabel: 'Bitbucket API token', creationHint: - 'Create an API token with scopes covering repository, pull request, and webhook read/write, plus workspace read (read:workspace:bitbucket) and user read (read:user:bitbucket) so Roomote can discover workspaces and validate the credentials. Prefer a bot or service account that can administer repository webhooks; Roomote syncs repositories and configures pull request webhooks automatically. The Atlassian account email that owns the API token is required.', + 'In Atlassian account settings, select Create API token with scopes → Bitbucket. Grant repository, pull request, and webhook read and write access, plus workspace and user read access. Use a bot or service account that can manage repository webhooks.', }, ado: { creationHref: 'https://dev.azure.com/_usersSettings/tokens', diff --git a/packages/types/src/setup-source-control-config.test.ts b/packages/types/src/setup-source-control-config.test.ts index 8b45a0a9..717389e3 100644 --- a/packages/types/src/setup-source-control-config.test.ts +++ b/packages/types/src/setup-source-control-config.test.ts @@ -429,4 +429,23 @@ describe('getSetupSourceControlVisibleFields', () => { expect(names).not.toContain('ADO_WEBHOOK_SECRET'); }); + + it.each([ + ['gitlab', ['GITLAB_TOKEN', 'GITLAB_BASE_URL']], + ['gitea', ['GITEA_BASE_URL', 'GITEA_TOKEN']], + ['bitbucket', ['BITBUCKET_TOKEN', 'BITBUCKET_USERNAME']], + ] as const)( + 'keeps %s setup focused on required connection values', + (provider, expected) => { + const descriptor = SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.find( + (candidate) => candidate.provider === provider, + ); + + expect( + getSetupSourceControlVisibleFields(descriptor?.fields ?? []).map( + (field) => field.envVarName, + ), + ).toEqual(expected); + }, + ); }); diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index bfa3369b..b7d48ac4 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -257,6 +257,7 @@ function buildProviderFields( acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], label: 'GitLab OAuth Client ID', required: false, + setupHidden: true, }, { envVarName: 'GITLAB_CLIENT_SECRET', @@ -264,6 +265,7 @@ function buildProviderFields( label: 'GitLab OAuth Client Secret', secret: true, required: false, + setupHidden: true, }, { envVarName: 'GITLAB_WEBHOOK_SIGNING_TOKEN', @@ -271,6 +273,7 @@ function buildProviderFields( label: 'GitLab Webhook Signing Token', secret: true, required: false, + setupHidden: true, }, { envVarName: 'GITLAB_WEBHOOK_SECRET', @@ -278,6 +281,7 @@ function buildProviderFields( label: 'GitLab Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; case 'gitea': @@ -298,12 +302,14 @@ function buildProviderFields( acceptedEnvVarNames: ['GITEA_USERNAME'], label: 'Gitea Username', required: false, + setupHidden: true, }, { envVarName: 'GITEA_CLIENT_ID', acceptedEnvVarNames: ['GITEA_CLIENT_ID'], label: 'Gitea OAuth Client ID', required: false, + setupHidden: true, }, { envVarName: 'GITEA_CLIENT_SECRET', @@ -311,6 +317,7 @@ function buildProviderFields( label: 'Gitea OAuth Client Secret', secret: true, required: false, + setupHidden: true, }, { envVarName: 'GITEA_WEBHOOK_SECRET', @@ -318,6 +325,7 @@ function buildProviderFields( label: 'Gitea Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; case 'ado': @@ -410,12 +418,14 @@ function buildProviderFields( acceptedEnvVarNames: ['BITBUCKET_BASE_URL'], label: 'Bitbucket Base URL', required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_CLIENT_ID', acceptedEnvVarNames: ['BITBUCKET_CLIENT_ID'], label: 'Bitbucket OAuth Client ID', required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_CLIENT_SECRET', @@ -423,6 +433,7 @@ function buildProviderFields( label: 'Bitbucket OAuth Client Secret', secret: true, required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_WEBHOOK_SECRET', @@ -430,6 +441,7 @@ function buildProviderFields( label: 'Bitbucket Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; } From b154e7182e8c197f14c68dddfac573cd8149b492 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 11:55:47 +0100 Subject: [PATCH 02/31] Add directional setup step transitions --- apps/web/src/app/(onboarding)/setup/hooks.ts | 80 +++++++++++++------- apps/web/src/app/(onboarding)/setup/page.tsx | 27 +++++++ apps/web/src/app/globals.css | 19 ++++- 3 files changed, 96 insertions(+), 30 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 74786918..35f55035 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -17,6 +17,7 @@ import { useSetupAsyncSession } from './setup-session'; import { hasSeenSetupWelcome } from './welcome-seen'; export type OpenRouterOauthEntryStatus = 'connected' | 'error'; +export type SetupStepTransitionDirection = 'forward' | 'backward'; type SetupEntryContext = { step: SetupStep | null; @@ -261,6 +262,9 @@ export function useSetupFlow( ); const [step, setStep] = useState('welcome'); + const [transitionDirection, setTransitionDirection] = + useState('forward'); + const stepRef = useRef('welcome'); const [entryContext] = useState(() => readUrlEntryContext(), ); @@ -273,6 +277,21 @@ export function useSetupFlow( const setupSession = useSetupAsyncSession({ currentTaskId: status?.setupNewState.onboardingTaskId ?? null, }); + stepRef.current = step; + + const setStepWithTransition = useCallback((nextStep: SetupStep) => { + const currentIndex = SETUP_STEPS.indexOf(stepRef.current); + const nextIndex = SETUP_STEPS.indexOf(nextStep); + + if (nextStep !== stepRef.current) { + setTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } + + stepRef.current = nextStep; + setStep(nextStep); + }, []); const communicationStepResolved = setupSession.session.communicationStep.state === 'skipped' || setupSession.session.communicationStep.state === 'completed'; @@ -613,7 +632,7 @@ export function useSetupFlow( initialized.current = true; const requestedStep = entryContext.step; const resolvedStep = resolveDeepLinkStep(requestedStep); - setStep(resolvedStep); + setStepWithTransition(resolvedStep); if (resolvedStep === requestedStep) { // Valid deep link: keep the canonical `step` in the URL and only drop @@ -629,6 +648,7 @@ export function useSetupFlow( entryContext.step, replaceStepUrl, resolveDeepLinkStep, + setStepWithTransition, setupSession.hydrated, status, ]); @@ -641,23 +661,22 @@ export function useSetupFlow( } const fallbackStep = findNextStep(0); + const currentStep = stepRef.current; - setStep((currentStep) => { - if (isInitialReplayVisit(status)) { - return currentStep; - } - - if (currentStep === pinnedUrlStepRef.current) { - return currentStep; - } - - if (shouldSkip(currentStep)) { - return fallbackStep; - } - - return currentStep; - }); - }, [findNextStep, setupSession.hydrated, shouldSkip, status]); + if ( + !isInitialReplayVisit(status) && + currentStep !== pinnedUrlStepRef.current && + shouldSkip(currentStep) + ) { + setStepWithTransition(fallbackStep); + } + }, [ + findNextStep, + setStepWithTransition, + setupSession.hydrated, + shouldSkip, + status, + ]); // Keep the URL in sync with the active step. User navigation and deep-link // corrections write the URL themselves (recorded in lastUrlStepRef); any @@ -715,12 +734,18 @@ export function useSetupFlow( replaceStepUrl(resolvedStep); } - setStep(resolvedStep); + setStepWithTransition(resolvedStep); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [replaceStepUrl, resolveDeepLinkStep, setupSession.hydrated, status]); + }, [ + replaceStepUrl, + resolveDeepLinkStep, + setStepWithTransition, + setupSession.hydrated, + status, + ]); useEffect(() => { if ( @@ -757,10 +782,10 @@ export function useSetupFlow( // navigation must remain subject to the skip rules on the next status // refresh. pinnedUrlStepRef.current = options.revisit ? nextStep : null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [pushStepUrl, step], + [pushStepUrl, setStepWithTransition, step], ); const previousStep = useMemo( @@ -791,9 +816,9 @@ export function useSetupFlow( navigationHistoryRef.current.push(step); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); - }, [findNextStep, pushStepUrl, step]); + }, [findNextStep, pushStepUrl, setStepWithTransition, step]); const goToNextPostOnboardingStep = useCallback( (forceUnlocked = false) => { @@ -802,10 +827,10 @@ export function useSetupFlow( navigationHistoryRef.current.push(step); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [findNextPostOnboardingStep, pushStepUrl, step], + [findNextPostOnboardingStep, pushStepUrl, setStepWithTransition, step], ); const advancePostOnboardingStep = useCallback( @@ -818,14 +843,15 @@ export function useSetupFlow( navigationHistoryRef.current.push(resolvedStep); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [findNextPostOnboardingStep, pushStepUrl], + [findNextPostOnboardingStep, pushStepUrl, setStepWithTransition], ); return { step, + transitionDirection, entryContext, goToStep, goToPreviousStep, diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index cef4521d..75a8ea5b 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; @@ -428,6 +429,30 @@ export default function SetupPage() { return (

+ + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.75, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.75, ease: 'easeOut' }, + }), + }} + initial="enter" + animate="center" + exit="exit" + > {step === 'welcome' && } {step === 'auth-provider' && ( undefined} /> )} + +
); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index c29e40fa..36a58f71 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -107,7 +107,7 @@ } } - --animate-enter-up: enter-up 0.75s ease-out 1; + --animate-enter-up: enter-up 0.25s ease-out 1; @keyframes enter-up { 0% { @@ -120,7 +120,7 @@ } } - --animate-enter-down: enter-down 0.75s ease-out 1; + --animate-enter-down: enter-down 0.25s ease-out 1; @keyframes enter-down { 0% { @@ -133,7 +133,7 @@ } } - --animate-exit-down: exit-down 0.75s ease-out 1; + --animate-exit-down: exit-down 0.25s ease-out 1; @keyframes exit-down { 0% { @@ -146,6 +146,19 @@ } } + --animate-exit-up: exit-up 0.25s ease-out 1; + + @keyframes exit-up { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(20px); + } + } + --animate-enter-message: enter-message 0.1s ease-out 1; @keyframes enter-message { From 299281ea7db51bb89bf551f606371b37eea5dedb Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 11:55:54 +0100 Subject: [PATCH 03/31] fix gitea base URL normalization --- packages/gitea/src/__tests__/api.test.ts | 9 +++++++++ packages/gitea/src/api.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/gitea/src/__tests__/api.test.ts b/packages/gitea/src/__tests__/api.test.ts index eb75199b..723024e0 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -130,6 +130,15 @@ describe('Gitea API helpers', () => { expect(buildGiteaApiBaseUrl('https://git.example.com/')).toBe( 'https://git.example.com/api/v1', ); + expect(buildGiteaApiBaseUrl('https://git.example.com/api/v1')).toBe( + 'https://git.example.com/api/v1', + ); + expect(buildGiteaApiBaseUrl('https://git.example.com/gitea/api/v1/')).toBe( + 'https://git.example.com/gitea/api/v1', + ); + expect(buildGiteaApiBaseUrl('https://gitea.com/roocode/')).toBe( + 'https://gitea.com/api/v1', + ); }); it('lists authenticated Gitea repositories with token auth and pagination', async () => { diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index ed740147..73cb5686 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -107,7 +107,16 @@ function normalizeBaseUrl(baseUrl: string): string { throw new Error('GITEA_BASE_URL cannot be empty.'); } - return new URL(trimmed).toString().replace(/\/+$/, ''); + const url = new URL(trimmed); + const apiPathSuffix = /\/api\/v1$/; + + if (url.hostname === 'gitea.com') { + url.pathname = '/'; + } else if (apiPathSuffix.test(url.pathname)) { + url.pathname = url.pathname.replace(apiPathSuffix, '') || '/'; + } + + return url.toString().replace(/\/+$/, ''); } export async function resolveGiteaToken(): Promise { From 5d16d85c48bb3fd29f06e24989679eb9f2c76b18 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 12:01:25 +0100 Subject: [PATCH 04/31] ui details --- .../setup/StepSourceControlConfig.tsx | 22 +- apps/web/src/app/(onboarding)/setup/page.tsx | 391 +++++++++--------- .../setup/sourceControlSetupCopy.ts | 4 +- apps/web/src/app/globals.css | 8 +- 4 files changed, 214 insertions(+), 211 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 467c8fa3..34db58f4 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -319,16 +319,18 @@ export function StepSourceControlConfig({ {providerSetupCopy ? ( <> Create a new {providerSetupCopy.setupLabel}. - + {creationHref && ( + + )} ) : ( <>Create a new {providerSetupLabel}. diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index 75a8ea5b..749ec204 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -146,6 +146,7 @@ export default function SetupPage() { ); const { step, + transitionDirection, entryContext, goToStep, goToPreviousStep, @@ -441,212 +442,214 @@ export default function SetupPage() { center: { opacity: 1, y: 0, - transition: { duration: 0.75, ease: 'easeOut' }, + transition: { duration: 0.25, ease: 'easeOut' }, }, exit: (direction) => ({ opacity: 0, y: direction === 'forward' ? -20 : 20, - transition: { duration: 0.75, ease: 'easeOut' }, + transition: { duration: 0.25, ease: 'easeOut' }, }), }} initial="enter" animate="center" exit="exit" > - {step === 'welcome' && } - {step === 'auth-provider' && ( - { - setPendingAuthProvider(provider); - goToStep('auth-env-vars'); - }} - onSkip={() => { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - /> - )} - {step === 'auth-env-vars' && - (pendingAuthProvider === 'telegram' ? ( - { - setupSession.setCommunicationStepState('completed'); - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - /> - ) : ( - { - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - bootstrapMode={false} - /> - ))} - {step === 'env-vars' && ( - - )} - {step === 'source-control-provider' && ( - { - saveSourceControlProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveSourceControlProviderChoice.isPending} - /> - )} - {step === 'source-control-config' && ( - { - setPendingSourceControlProvider(null); - goToStep('source-control-connect'); - }} - onBack={ - canGoBack - ? () => { - setPendingSourceControlProvider(null); - goToPreviousStep(); + {step === 'welcome' && } + {step === 'auth-provider' && ( + { + setPendingAuthProvider(provider); + goToStep('auth-env-vars'); + }} + onSkip={() => { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + /> + )} + {step === 'auth-env-vars' && + (pendingAuthProvider === 'telegram' ? ( + { + setupSession.setCommunicationStepState('completed'); + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined } - : undefined - } - /> - )} - {step === 'source-control-connect' && ( - - )} - {step === 'qualification-blocked' && - status.setupQualification.activeBlock ? ( - - ) : null} - {step === 'compute-provider' && ( - { - saveComputeProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveComputeProviderChoice.isPending} - /> - )} - {step === 'compute-config' && ( - { - setPendingComputeProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingComputeProvider(null); - goToPreviousStep(); + /> + ) : ( + { + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined } - : undefined - } - /> - )} - {step === 'slack' && ( - { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - returnPath={getSetupStepPath('slack')} - /> - )} - {step === 'repo-selection' && ( - - goToStep('compute-config', { revisit: true }) - } - onReviewComputeProvider={() => - goToStep('compute-provider', { revisit: true }) - } - onContinue={() => goToStep('invoke')} - onBack={canGoBack ? goToPreviousStep : undefined} - onSkip={() => { - setupSession.unlockPostOnboardingFlow(); - goToNextPostOnboardingStep(true); - }} - /> - )} - {step === 'invoke' && ( - - goToStep('compute-config', { revisit: true }) - } - onTryItOut={() => undefined} - /> - )} + bootstrapMode={false} + /> + ))} + {step === 'env-vars' && ( + + )} + {step === 'source-control-provider' && ( + { + saveSourceControlProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveSourceControlProviderChoice.isPending} + /> + )} + {step === 'source-control-config' && ( + { + setPendingSourceControlProvider(null); + goToStep('source-control-connect'); + }} + onBack={ + canGoBack + ? () => { + setPendingSourceControlProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + )} + {step === 'source-control-connect' && ( + + )} + {step === 'qualification-blocked' && + status.setupQualification.activeBlock ? ( + + ) : null} + {step === 'compute-provider' && ( + { + saveComputeProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveComputeProviderChoice.isPending} + /> + )} + {step === 'compute-config' && ( + { + setPendingComputeProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingComputeProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + )} + {step === 'slack' && ( + { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + returnPath={getSetupStepPath('slack')} + /> + )} + {step === 'repo-selection' && ( + + goToStep('compute-config', { revisit: true }) + } + onReviewComputeProvider={() => + goToStep('compute-provider', { revisit: true }) + } + onContinue={() => goToStep('invoke')} + onBack={canGoBack ? goToPreviousStep : undefined} + onSkip={() => { + setupSession.unlockPostOnboardingFlow(); + goToNextPostOnboardingStep(true); + }} + /> + )} + {step === 'invoke' && ( + + goToStep('compute-config', { revisit: true }) + } + onTryItOut={() => undefined} + /> + )} diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index add101d2..918cbe87 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -1,7 +1,7 @@ import type { SourceControlProvider } from '@roomote/types'; type SourceControlSetupCopy = { - creationHref: string; + creationHref?: string; creationLinkLabel?: string; setupLabel: string; /** Indefinite article for `setupLabel` ("a" unless the label needs "an"). */ @@ -24,8 +24,6 @@ const SOURCE_CONTROL_SETUP_COPY: Record< 'In GitLab, go to your avatar → Edit profile → Access tokens → Add new token. Select the api scope. Use a bot or service account that belongs to the groups Roomote should access and can manage project webhooks.', }, gitea: { - creationHref: 'https://docs.gitea.com/development/api-usage', - creationLinkLabel: 'View Gitea guide', setupLabel: 'Gitea access token', creationHint: 'In your Gitea instance, go to your avatar → Settings → Applications → Manage Access Tokens → Generate Token. Use a bot or service account with read and write access to the repositories Roomote should use and permission to manage their webhooks.', diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 36a58f71..3627258b 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -107,7 +107,7 @@ } } - --animate-enter-up: enter-up 0.25s ease-out 1; + --animate-enter-up: enter-up 0.75s ease-out 1; @keyframes enter-up { 0% { @@ -120,7 +120,7 @@ } } - --animate-enter-down: enter-down 0.25s ease-out 1; + --animate-enter-down: enter-down 0.75s ease-out 1; @keyframes enter-down { 0% { @@ -133,7 +133,7 @@ } } - --animate-exit-down: exit-down 0.25s ease-out 1; + --animate-exit-down: exit-down 0.75s ease-out 1; @keyframes exit-down { 0% { @@ -146,7 +146,7 @@ } } - --animate-exit-up: exit-up 0.25s ease-out 1; + --animate-exit-up: exit-up 0.75s ease-out 1; @keyframes exit-up { 0% { From 13176d29492699aa158dc6614d85fdf2d1aa5154 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 12:09:52 +0100 Subject: [PATCH 05/31] Animate pre-sign-in setup steps --- apps/web/src/app/(onboarding)/setup/page.tsx | 186 +++++++++++++------ 1 file changed, 128 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index 749ec204..6a44764a 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; @@ -94,6 +94,14 @@ function getInitialBootstrapStep(): BootstrapStep { ); } +const BOOTSTRAP_STEPS: readonly BootstrapStep[] = [ + 'welcome', + 'email-account', + 'email-password', + 'auth-provider', + 'auth-env-vars', +]; + export default function SetupPage() { const router = useRouter(); const setupBootstrapOpen = useSetupBootstrapOpen(); @@ -106,6 +114,26 @@ export default function SetupPage() { const [bootstrapStep, setBootstrapStep] = useState( getInitialBootstrapStep, ); + const [bootstrapTransitionDirection, setBootstrapTransitionDirection] = + useState<'forward' | 'backward'>('forward'); + const bootstrapStepRef = useRef(bootstrapStep); + bootstrapStepRef.current = bootstrapStep; + const setBootstrapStepWithTransition = useCallback( + (nextStep: BootstrapStep) => { + const currentIndex = BOOTSTRAP_STEPS.indexOf(bootstrapStepRef.current); + const nextIndex = BOOTSTRAP_STEPS.indexOf(nextStep); + + if (nextStep !== bootstrapStepRef.current) { + setBootstrapTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } + + bootstrapStepRef.current = nextStep; + setBootstrapStep(nextStep); + }, + [], + ); const [pendingAuthProvider, setPendingAuthProvider] = useState(null); const [pendingSourceControlProvider, setPendingSourceControlProvider] = @@ -291,26 +319,36 @@ export default function SetupPage() { return; } - setBootstrapStep((currentStep) => { - if (currentStep === 'welcome') { - return currentStep; - } + const currentStep = bootstrapStepRef.current; + let nextStep = currentStep; + if (currentStep !== 'welcome') { if (currentStep === 'email-account' || currentStep === 'email-password') { const nextBootstrapStep = getBootstrapStepAfterWelcome( bootstrapStatus.authSetup, ); - return nextBootstrapStep === 'email-account' - ? currentStep - : nextBootstrapStep; + nextStep = + nextBootstrapStep === 'email-account' + ? currentStep + : nextBootstrapStep; + } else { + nextStep = getNextBootstrapStep( + bootstrapStatus.authSetup, + pendingAuthProvider === 'telegram' ? null : pendingAuthProvider, + ); } + } - return getNextBootstrapStep( - bootstrapStatus.authSetup, - pendingAuthProvider === 'telegram' ? null : pendingAuthProvider, - ); - }); - }, [bootstrapStatus, isSignedIn, pendingAuthProvider, router]); + if (nextStep !== currentStep) { + setBootstrapStepWithTransition(nextStep); + } + }, [ + bootstrapStatus, + isSignedIn, + pendingAuthProvider, + router, + setBootstrapStepWithTransition, + ]); useEffect(() => { if (status?.setupNewState.authProvider) { @@ -354,51 +392,83 @@ export default function SetupPage() { return (
- {bootstrapStep === 'welcome' && ( - { - setBootstrapStep( - getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), - ); - }} - /> - )} - {bootstrapStep === 'email-account' && ( - { - setPendingAuthProvider(provider); - setBootstrapStep( - getNextBootstrapStep(bootstrapStatus.authSetup, provider), - ); - }} - onUseEmailPassword={() => setBootstrapStep('email-password')} - /> - )} - {bootstrapStep === 'email-password' && ( - setBootstrapStep('email-account')} - /> - )} - {bootstrapStep === 'auth-provider' && ( - { - if (provider === 'telegram') return; - setPendingAuthProvider(provider); - setBootstrapStep('auth-env-vars'); + + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.75, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.75, ease: 'easeOut' }, + }), }} - onBack={() => setBootstrapStep('email-account')} - /> - )} - {bootstrapStep === 'auth-env-vars' && ( - undefined} - onBack={() => setBootstrapStep('email-account')} - bootstrapMode={true} - setupToken={setupToken} - /> - )} + initial="enter" + animate="center" + exit="exit" + > + {bootstrapStep === 'welcome' && ( + { + setBootstrapStepWithTransition( + getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), + ); + }} + /> + )} + {bootstrapStep === 'email-account' && ( + { + setPendingAuthProvider(provider); + setBootstrapStepWithTransition( + getNextBootstrapStep(bootstrapStatus.authSetup, provider), + ); + }} + onUseEmailPassword={() => + setBootstrapStepWithTransition('email-password') + } + /> + )} + {bootstrapStep === 'email-password' && ( + setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-provider' && ( + { + if (provider === 'telegram') return; + setPendingAuthProvider(provider); + setBootstrapStepWithTransition('auth-env-vars'); + }} + onBack={() => setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-env-vars' && ( + undefined} + onBack={() => setBootstrapStepWithTransition('email-account')} + bootstrapMode={true} + setupToken={setupToken} + /> + )} + +
); } From 3a78ac4e7c7df419409e02ad1f12a3b2fb82774a Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 12:13:32 +0100 Subject: [PATCH 06/31] Match setup transition timing --- apps/web/src/app/(onboarding)/setup/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index 6a44764a..60f7b285 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -408,12 +408,12 @@ export default function SetupPage() { center: { opacity: 1, y: 0, - transition: { duration: 0.75, ease: 'easeOut' }, + transition: { duration: 0.25, ease: 'easeOut' }, }, exit: (direction) => ({ opacity: 0, y: direction === 'forward' ? -20 : 20, - transition: { duration: 0.75, ease: 'easeOut' }, + transition: { duration: 0.25, ease: 'easeOut' }, }), }} initial="enter" From b038f7d39e713a999daf8b855cff714514b494d5 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 12:20:32 +0100 Subject: [PATCH 07/31] normalize gitea URLs when saving config --- apps/web/src/trpc/commands/setup-new/index.test.ts | 9 +++++++-- .../commands/setup-new/launch-lifecycle.test.ts | 2 ++ .../src/trpc/commands/source-control/index.test.ts | 2 ++ apps/web/src/trpc/commands/source-control/index.ts | 10 +++++++--- packages/gitea/src/__tests__/api.test.ts | 5 +++++ packages/gitea/src/api.ts | 14 ++++++++------ 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index ca40c875..bba979da 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -51,6 +51,8 @@ vi.mock('@roomote/github', () => ({ })); vi.mock('@roomote/gitea', () => ({ + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, resolveGiteaBaseUrl: mockResolveGiteaBaseUrl, validateGiteaToken: mockValidateGiteaToken, })); @@ -532,7 +534,7 @@ describe('setup-new source-control config commands', () => { { provider: 'gitea', values: { - GITEA_BASE_URL: 'https://gitea.example.com', + GITEA_BASE_URL: 'gitea.example.com', GITEA_TOKEN: 'gitea-token', }, }, @@ -548,7 +550,10 @@ describe('setup-new source-control config commands', () => { expect.objectContaining({ userId: 'setup-test-user', values: expect.arrayContaining([ - expect.objectContaining({ name: 'GITEA_BASE_URL' }), + expect.objectContaining({ + name: 'GITEA_BASE_URL', + value: 'https://gitea.example.com', + }), expect.objectContaining({ name: 'GITEA_TOKEN' }), ]), }), diff --git a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts index ced30d0d..c2228cc7 100644 --- a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts +++ b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts @@ -34,6 +34,8 @@ vi.mock('@roomote/github', () => ({ })); vi.mock('@roomote/gitea', () => ({ + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, resolveGiteaBaseUrl: vi.fn(async () => 'https://gitea.example.com'), validateGiteaToken: vi.fn(async () => ({ status: 'valid' })), })); diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 19565e6c..c132ea6f 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -47,6 +47,8 @@ vi.mock('@roomote/ado', () => ({ vi.mock('@roomote/gitea', () => ({ ensureGiteaWebhooksForRepositories: mockEnsureGiteaWebhooksForRepositories, + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, removeGiteaWebhooksForRepositories: mockRemoveGiteaWebhooksForRepositories, resolveGiteaBaseUrl: vi.fn().mockResolvedValue('https://gitea.example.com'), syncGiteaRepositories: mockSyncGiteaRepositories, diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 7b4978a8..ba9087f1 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -641,7 +641,10 @@ export async function saveSourceControlConfigValues(params: { return [ { name: field.envVarName, - value: nextValue, + value: + field.envVarName === 'GITEA_BASE_URL' + ? Gitea.normalizeGiteaBaseUrl(nextValue) + : nextValue, }, ]; }); @@ -783,8 +786,9 @@ export async function assertValidSourceControlConfigInput(params: { if (nextGiteaToken) { const nextGiteaBaseUrl = - params.values?.['GITEA_BASE_URL']?.trim() ?? - (await Gitea.resolveGiteaBaseUrl()); + (params.values?.['GITEA_BASE_URL']?.trim() + ? Gitea.normalizeGiteaBaseUrl(params.values['GITEA_BASE_URL']) + : undefined) ?? (await Gitea.resolveGiteaBaseUrl()); if (!nextGiteaBaseUrl) { return; diff --git a/packages/gitea/src/__tests__/api.test.ts b/packages/gitea/src/__tests__/api.test.ts index 723024e0..51f4b1c7 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -62,6 +62,7 @@ vi.mock('@roomote/db/encryption', () => ({ import { buildGiteaApiBaseUrl, + normalizeGiteaBaseUrl, buildGiteaRepositoryValues, createTaskRunGiteaCredentials, createGiteaPullRequestComment, @@ -139,6 +140,10 @@ describe('Gitea API helpers', () => { expect(buildGiteaApiBaseUrl('https://gitea.com/roocode/')).toBe( 'https://gitea.com/api/v1', ); + expect(normalizeGiteaBaseUrl('gitea.com')).toBe('https://gitea.com'); + expect(normalizeGiteaBaseUrl('git.example.com/')).toBe( + 'https://git.example.com', + ); }); it('lists authenticated Gitea repositories with token auth and pagination', async () => { diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 73cb5686..7296dc2a 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -100,14 +100,16 @@ export type GiteaWebhookEnsureResult = { error?: string; }; -function normalizeBaseUrl(baseUrl: string): string { +export function normalizeGiteaBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/, ''); if (!trimmed) { throw new Error('GITEA_BASE_URL cannot be empty.'); } - const url = new URL(trimmed); + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); const apiPathSuffix = /\/api\/v1$/; if (url.hostname === 'gitea.com') { @@ -131,7 +133,7 @@ let cachedGiteaDeploymentUser: { export async function resolveGiteaBaseUrl(): Promise { const baseUrl = await resolveDeploymentEnvVar('GITEA_BASE_URL'); - return baseUrl ? normalizeBaseUrl(baseUrl) : null; + return baseUrl ? normalizeGiteaBaseUrl(baseUrl) : null; } export async function resolveGiteaUsername(): Promise { @@ -139,7 +141,7 @@ export async function resolveGiteaUsername(): Promise { } export function buildGiteaApiBaseUrl(baseUrl: string): string { - return new URL('api/v1', `${normalizeBaseUrl(baseUrl)}/`).toString(); + return new URL('api/v1', `${normalizeGiteaBaseUrl(baseUrl)}/`).toString(); } function buildGiteaApiUrl( @@ -332,11 +334,11 @@ export async function listGiteaRepositories({ } function hostFromBaseUrl(baseUrl: string): string { - return new URL(normalizeBaseUrl(baseUrl)).host; + return new URL(normalizeGiteaBaseUrl(baseUrl)).host; } function buildGiteaWebUrl(baseUrl: string, path: string): string { - return new URL(path.replace(/^\//, ''), `${normalizeBaseUrl(baseUrl)}/`) + return new URL(path.replace(/^\//, ''), `${normalizeGiteaBaseUrl(baseUrl)}/`) .toString() .replace(/\/+$/, ''); } From 3d002c030c066e4d86f612fa8bcbc454670960cf Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 13:37:55 +0100 Subject: [PATCH 08/31] ui --- apps/docs/environment-variables.mdx | 2 +- apps/docs/providers/source-control/gitlab.mdx | 31 +++++++++++-------- .../StepSourceControlConfig.client.test.tsx | 19 +++++++----- .../setup/StepSourceControlConfig.tsx | 29 ++++++++++++++--- .../setup/sourceControlSetupCopy.ts | 7 ++--- .../settings/SourceControl.test.tsx | 2 +- .../commands/source-control/index.test.ts | 5 +++ .../src/trpc/commands/source-control/index.ts | 9 +++++- packages/gitlab/src/__tests__/api.test.ts | 12 +++++++ packages/gitlab/src/api.ts | 23 ++++++++++---- .../types/src/setup-source-control-config.ts | 2 +- 11 files changed, 101 insertions(+), 40 deletions(-) diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 0e3c30ea..8ddd5a16 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -247,7 +247,7 @@ as per-task auth tokens or workspace paths. | `GITHUB_MCP_SERVER_URL` | Optional | GitHub MCP server URL override. | | `GITHUB_AUTOMATED_SKIP_REPOS` | Optional | Repository skip list for automated GitHub processing. | | `GITHUB_AUTOMATED_SKIP_OWNERS` | Optional | Owner skip list for automated GitHub processing. | -| `GITLAB_TOKEN` | GitLab | GitLab personal access token for source-control setup. | +| `GITLAB_TOKEN` | GitLab | GitLab automation token for source-control setup; prefer a dedicated bot/service identity over a personal user account. | | `GITLAB_BASE_URL` | Optional | GitLab base URL for self-managed GitLab. | | `GITLAB_CLIENT_ID` | Optional | GitLab OAuth application ID for personal account linking and merge request comment triggers. | | `GITLAB_CLIENT_SECRET` | Optional | GitLab OAuth application secret for personal account linking and merge request comment triggers. | diff --git a/apps/docs/providers/source-control/gitlab.mdx b/apps/docs/providers/source-control/gitlab.mdx index bece2667..2b6e92b2 100644 --- a/apps/docs/providers/source-control/gitlab.mdx +++ b/apps/docs/providers/source-control/gitlab.mdx @@ -4,18 +4,23 @@ icon: 'https://api.iconify.design/simple-icons:gitlab.svg?color=currentColor' description: Configure GitLab repository sync and merge request webhooks for Roomote. --- -GitLab support uses a deployment-owned token. There is no self-serve GitLab App -or OAuth installation flow yet, so an operator provides a GitLab token, syncs -repositories from Settings, and configures webhooks when review automation is -needed. +GitLab does not have a GitHub-style App installation flow. GitLab OAuth +applications are for delegated user access, not for granting Roomote an +independent installation identity. Roomote therefore uses a deployment-owned +automation token for repository sync, task execution, merge-request notes, and +webhook management. Use a dedicated bot or service identity rather than a +personal developer account. Use `` below for your stable public Roomote URL. -## Create a GitLab bot token +## Create a GitLab automation token -Create a dedicated GitLab bot or service-account user and add it to the groups -or projects Roomote should access. Create an access token for that identity -with scopes that match the behavior you want: +Add a dedicated GitLab bot or service-account identity to the groups or projects +Roomote should access. Prefer a group or project access token when it fits your +GitLab plan and repository layout. If Roomote needs to enumerate repositories +across memberships or create short-lived project tokens, create a personal +access token for the dedicated bot account instead—not for an individual +operator. Give the token scopes that match the behavior you want: | Scope | Needed for | | ------------------ | ------------------------------------------------ | @@ -31,9 +36,10 @@ GITLAB_TOKEN= For self-managed GitLab, also set `GITLAB_BASE_URL`. -The setup flow asks only for the access token and, when needed, the self-managed -GitLab base URL. OAuth and webhook verification settings remain available under -Settings for deployments that need them. +The setup flow asks only for the automation token and, when needed, the +self-managed GitLab base URL. OAuth is configured separately and is optional; +it is only needed when teammates should link their GitLab accounts for +merge-request comment triggers. ## Sync repositories @@ -75,8 +81,7 @@ GITLAB_WEBHOOK_SECRET= Users can trigger Roomote tasks by mentioning `@roomote` in merge request comments, but only after linking their personal GitLab account in Roomote's -Personal settings. That linking flow requires a GitLab OAuth application, so -create one even though the fields are optional: +Personal settings. This is a separate, optional GitLab OAuth application: 1. Open **Applications** in GitLab — on the bot account (`/-/user_settings/applications`), a group, or the admin area for a diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 21270176..644c95e5 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -114,7 +114,7 @@ function buildSourceControlSetup( { envVarName: 'GITLAB_TOKEN', acceptedEnvVarNames: ['GITLAB_TOKEN'], - label: 'GitLab Personal Access Token', + label: 'GitLab Automation Token', secret: true, runtimeSatisfied: false, savedSatisfied: false, @@ -239,7 +239,7 @@ describe('StepSourceControlConfig', () => { ).toBeInTheDocument(); }); - it('guides GitLab token creation without showing OAuth configuration', () => { + it('guides GitLab automation token setup', () => { render( { />, ); - expect( - screen.getByText('GitLab Personal Access Token'), - ).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Open/ })).toHaveAttribute( + expect(screen.getByText('GitLab Automation Token')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute( 'href', 'https://gitlab.com/-/user_settings/personal_access_tokens', ); expect( - screen.getByText(/avatar → Edit profile → Access tokens/), + screen.getByText( + /avatar → Edit profile → Access → Personal access tokens/, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/OAuth application setup is separate and optional/), ).toBeInTheDocument(); expect( - screen.queryByText(/GitLab OAuth Client ID/), + screen.queryByText(/Recommended: create a GitLab OAuth application/), ).not.toBeInTheDocument(); expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 34db58f4..939f3991 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -73,6 +73,27 @@ function filterValuesToFields( return next; } +function normalizeGitLabSetupUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed) { + return 'https://gitlab.com'; + } + + try { + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + if (url.hostname === 'gitlab.com') { + url.pathname = '/'; + } else if (/\/api\/v4$/.test(url.pathname)) { + url.pathname = url.pathname.replace(/\/api\/v4$/, '') || '/'; + } + return url.toString().replace(/\/+$/, ''); + } catch { + return 'https://gitlab.com'; + } +} + export function StepSourceControlConfig({ sourceControlSetup, selectedProviderId, @@ -287,11 +308,9 @@ export function StepSourceControlConfig({ values['GITLAB_BASE_URL']?.trim().replace(/\/+$/, '') ?? ''; const configuredGitLabBaseUrl = sourceControlSetup.gitlabBaseUrl?.trim().replace(/\/+$/, '') ?? ''; - const effectiveGitLabBaseUrl = /^https?:\/\//.test(typedGitLabBaseUrl) - ? typedGitLabBaseUrl - : /^https?:\/\//.test(configuredGitLabBaseUrl) - ? configuredGitLabBaseUrl - : 'https://gitlab.com'; + const effectiveGitLabBaseUrl = normalizeGitLabSetupUrl( + typedGitLabBaseUrl || configuredGitLabBaseUrl, + ); const creationHref = selectedProvider?.provider === 'gitlab' ? `${effectiveGitLabBaseUrl}/-/user_settings/personal_access_tokens` diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index 918cbe87..41af453f 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -18,15 +18,14 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { - creationHref: 'https://gitlab.com/-/user_settings/personal_access_tokens', - setupLabel: 'GitLab access token', + setupLabel: 'GitLab automation token', creationHint: - 'In GitLab, go to your avatar → Edit profile → Access tokens → Add new token. Select the api scope. Use a bot or service account that belongs to the groups Roomote should access and can manage project webhooks.', + 'In GitLab, go to your avatar → Edit profile → Access → Personal access tokens → Add new token. If you go with fine-grained, make sure to add full read/write permissions for repositories, and "all groups and projects I\'member of" if you want to use your org\'s repos.', }, gitea: { setupLabel: 'Gitea access token', creationHint: - 'In your Gitea instance, go to your avatar → Settings → Applications → Manage Access Tokens → Generate Token. Use a bot or service account with read and write access to the repositories Roomote should use and permission to manage their webhooks.', + 'In your Gitea instance, go to your avatar → Settings → Applications → Manage Access Tokens → Generate Token. Give it at least read + write access to repositories.', }, bitbucket: { creationHref: 'https://id.atlassian.com/manage-profile/security/api-tokens', diff --git a/apps/web/src/components/settings/SourceControl.test.tsx b/apps/web/src/components/settings/SourceControl.test.tsx index 90c9ec70..4dcec346 100644 --- a/apps/web/src/components/settings/SourceControl.test.tsx +++ b/apps/web/src/components/settings/SourceControl.test.tsx @@ -564,7 +564,7 @@ describe('SourceControl settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); expect( - screen.getByRole('link', { name: /GitLab access token/ }), + screen.getByRole('link', { name: /GitLab automation token/ }), ).toHaveAttribute( 'href', 'https://gitlab.com/-/user_settings/personal_access_tokens', diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index c132ea6f..ff26c385 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -56,8 +56,13 @@ vi.mock('@roomote/gitea', () => ({ })); vi.mock('@roomote/gitlab', () => ({ + buildGitLabApiBaseUrl: (value: string) => + `${value.replace(/\/+$/, '')}/api/v4`, ensureGitLabWebhooksForProjects: vi.fn(), + normalizeGitLabBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, removeGitLabWebhooksForProjects: mockRemoveGitLabWebhooksForProjects, + resolveGitLabBaseUrl: vi.fn().mockResolvedValue('https://gitlab.com'), syncGitLabRepositories: vi.fn(), validateGitLabToken: vi.fn().mockResolvedValue({ status: 'valid' }), })); diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index ba9087f1..604639a9 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -644,7 +644,9 @@ export async function saveSourceControlConfigValues(params: { value: field.envVarName === 'GITEA_BASE_URL' ? Gitea.normalizeGiteaBaseUrl(nextValue) - : nextValue, + : field.envVarName === 'GITLAB_BASE_URL' + ? GitLab.normalizeGitLabBaseUrl(nextValue) + : nextValue, }, ]; }); @@ -775,8 +777,13 @@ export async function assertValidSourceControlConfigInput(params: { } if (nextGitLabToken) { + const nextGitLabBaseUrl = + (params.values?.['GITLAB_BASE_URL']?.trim() + ? GitLab.normalizeGitLabBaseUrl(params.values['GITLAB_BASE_URL']) + : undefined) ?? (await GitLab.resolveGitLabBaseUrl()); const validation = await GitLab.validateGitLabToken({ token: nextGitLabToken, + apiBaseUrl: GitLab.buildGitLabApiBaseUrl(nextGitLabBaseUrl), }); if (validation.status === 'invalid') { diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 3ac5c335..30e84add 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -73,6 +73,7 @@ import { revokeGitLabScopedProjectToken, type GitLabProject, listGitLabProjects, + normalizeGitLabBaseUrl, validateGitLabToken, } from '../api'; @@ -114,6 +115,17 @@ describe('resolveGitLabBaseUrl', () => { 'https://gitlab.example.com', ); }); + + it('accepts scheme-less URLs and removes API or hosted-account paths', async () => { + process.env.GITLAB_BASE_URL = 'gitlab.example.com/api/v4/'; + + await expect(resolveGitLabBaseUrl()).resolves.toBe( + 'https://gitlab.example.com', + ); + expect(normalizeGitLabBaseUrl('gitlab.com/roomote/')).toBe( + 'https://gitlab.com', + ); + }); }); describe('buildGitLabApiBaseUrl', () => { diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 8f60d450..7cc6f374 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -108,14 +108,25 @@ export type ListGitLabProjectsOptions = { stopAfter?: number; }; -function normalizeBaseUrl(baseUrl: string): string { +export function normalizeGitLabBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/, ''); if (!trimmed) { throw new Error('GITLAB_BASE_URL cannot be empty.'); } - return new URL(trimmed).toString().replace(/\/+$/, ''); + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + const apiPathSuffix = /\/api\/v4$/; + + if (url.hostname === 'gitlab.com') { + url.pathname = '/'; + } else if (apiPathSuffix.test(url.pathname)) { + url.pathname = url.pathname.replace(apiPathSuffix, '') || '/'; + } + + return url.toString().replace(/\/+$/, ''); } export async function resolveGitLabToken(): Promise { @@ -203,19 +214,19 @@ export async function createGitLabMergeRequestNote({ export async function resolveGitLabBaseUrl(): Promise { const baseUrl = await resolveDeploymentEnvVar('GITLAB_BASE_URL'); - return normalizeBaseUrl(baseUrl ?? DEFAULT_GITLAB_BASE_URL); + return normalizeGitLabBaseUrl(baseUrl ?? DEFAULT_GITLAB_BASE_URL); } export function buildGitLabApiBaseUrl(baseUrl: string): string { - return new URL('api/v4', `${normalizeBaseUrl(baseUrl)}/`).toString(); + return new URL('api/v4', `${normalizeGitLabBaseUrl(baseUrl)}/`).toString(); } function hostFromBaseUrl(baseUrl: string): string { - return new URL(normalizeBaseUrl(baseUrl)).host; + return new URL(normalizeGitLabBaseUrl(baseUrl)).host; } function buildGitLabWebUrl(baseUrl: string, path: string): string { - return new URL(path.replace(/^\//, ''), `${normalizeBaseUrl(baseUrl)}/`) + return new URL(path.replace(/^\//, ''), `${normalizeGitLabBaseUrl(baseUrl)}/`) .toString() .replace(/\/+$/, ''); } diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index b7d48ac4..c666068a 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -243,7 +243,7 @@ function buildProviderFields( { envVarName: 'GITLAB_TOKEN', acceptedEnvVarNames: ['GITLAB_TOKEN'], - label: 'GitLab Personal Access Token', + label: 'GitLab Automation Token', secret: true, }, { From 27616117773c732d15e29876d3f994e3b9d7694c Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 13:44:44 +0100 Subject: [PATCH 09/31] feat(gitlab): add deployment OAuth connection --- .../setup/StepSourceControlConfig.tsx | 31 ++- .../setup/StepSourceControlConnect.tsx | 24 ++ .../setup/sourceControlSetupCopy.ts | 4 +- .../gitlab/oauth/authorize/route.ts | 46 ++++ .../gitlab/oauth/callback/route.ts | 65 +++++ .../src/trpc/commands/source-control/index.ts | 20 ++ packages/gitlab/src/api.ts | 31 ++- packages/gitlab/src/index.ts | 1 + packages/gitlab/src/oauth.ts | 239 ++++++++++++++++++ .../types/src/setup-source-control-config.ts | 4 +- 10 files changed, 454 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts create mode 100644 apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts create mode 100644 packages/gitlab/src/oauth.ts diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 939f3991..9bb43820 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -116,6 +116,8 @@ export function StepSourceControlConfig({ >({}); const [showManualGitHubValues, setShowManualGitHubValues] = useState(false); const [showAdoAdvancedConfig, setShowAdoAdvancedConfig] = useState(false); + const [showGitlabAdvancedConfig, setShowGitlabAdvancedConfig] = + useState(false); const [adoAuthMode, setAdoAuthMode] = useState<'pat' | 'entra' | 'delegated'>( DEFAULT_ADO_AUTH_MODE, ); @@ -168,7 +170,9 @@ export function StepSourceControlConfig({ const visibleFields = useMemo( () => getSetupSourceControlVisibleFields(providerFields, { - showAdvancedConfig: isAdo && showAdoAdvancedConfig, + showAdvancedConfig: + (isAdo && showAdoAdvancedConfig) || + (selectedProvider?.provider === 'gitlab' && showGitlabAdvancedConfig), }).filter((field) => !isAdo ? true @@ -205,6 +209,7 @@ export function StepSourceControlConfig({ setEditingSavedValues({}); setShowManualGitHubValues(false); setShowAdoAdvancedConfig(false); + setShowGitlabAdvancedConfig(false); const hasAdoEntraCredentials = providerFields.some( (field) => ['ADO_CLIENT_ID', 'ADO_CLIENT_SECRET', 'ADO_TENANT_ID'].includes( @@ -403,6 +408,16 @@ export function StepSourceControlConfig({ Roomote should use.

)} + {selectedProvider?.provider === 'gitlab' && ( +

+ OAuth redirect URI:{' '} + + {publicOrigin}/api/source-control/gitlab/oauth/callback + + . Authorize GitLab with the dedicated service account after saving + the application credentials. +

+ )} {(isAdo ? baseFields : visibleFields).map((field) => ( ))} - {isAdo && advancedFields.length > 0 ? ( + {(isAdo || selectedProvider?.provider === 'gitlab') && + advancedFields.length > 0 ? (
) : null} - {isAdo && showAdoAdvancedConfig + {(isAdo ? showAdoAdvancedConfig : showGitlabAdvancedConfig) && + advancedFields.length > 0 ? advancedFields.map((field) => ( + field.envVarName === 'GITLAB_CLIENT_ID' && + (field.runtimeSatisfied || field.savedSatisfied), + ) && + providerStatus?.fields.some( + (field) => + field.envVarName === 'GITLAB_CLIENT_SECRET' && + (field.runtimeSatisfied || field.savedSatisfied), + ); const handleSyncRepositories = async () => { if ( @@ -265,6 +277,18 @@ export function StepSourceControlConnect({ ) : (

{tokenBackedCopy}

+ {gitlabOAuthConfigured ? ( +

+ Authorize the GitLab application with the dedicated service + account before syncing repositories. + + Authorize GitLab + +

+ ) : null} {syncedWithZeroRepos ? (

No repositories were found. Check your token permissions and base diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index 41af453f..3f10c32d 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -18,9 +18,9 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { - setupLabel: 'GitLab automation token', + setupLabel: 'GitLab OAuth application', creationHint: - 'In GitLab, go to your avatar → Edit profile → Access → Personal access tokens → Add new token. If you go with fine-grained, make sure to add full read/write permissions for repositories, and "all groups and projects I\'member of" if you want to use your org\'s repos.', + 'Create an OAuth application in GitLab with api, read_repository, and write_repository scopes. Use the exact redirect URI shown by Roomote, then authorize it with the dedicated service account. Existing PAT deployments can use the advanced migration path.', }, gitea: { setupLabel: 'Gitea access token', diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts new file mode 100644 index 00000000..5d6fac49 --- /dev/null +++ b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGitLabOAuthRedirectUri, + createGitLabOAuthAuthorizationUrl, + resolveGitLabBaseUrl, +} from '@roomote/gitlab'; +import { authorize } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + const authResult = await authorize(); + const webEnv = await bootstrapWebRuntimeEnv(); + if (!authResult.success || !authResult.isAdmin) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + const [clientId, baseUrl] = await Promise.all([ + resolveDeploymentEnvVar('GITLAB_CLIENT_ID'), + resolveGitLabBaseUrl(), + ]); + if (!clientId) + return NextResponse.json( + { error: 'GitLab OAuth client ID is not configured.' }, + { status: 400 }, + ); + + const redirectUri = buildGitLabOAuthRedirectUri(webEnv.R_APP_URL); + const { url, state } = createGitLabOAuthAuthorizationUrl({ + baseUrl, + clientId, + redirectUri, + }); + const response = NextResponse.redirect(url); + response.cookies.set('roomote-gitlab-oauth-state', state, { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitlab/oauth', + maxAge: 600, + }); + return response; +} diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts new file mode 100644 index 00000000..5402b182 --- /dev/null +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts @@ -0,0 +1,65 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGitLabOAuthRedirectUri, + exchangeGitLabOAuthCode, + resolveGitLabBaseUrl, +} from '@roomote/gitlab'; +import { authorize } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const webEnv = await bootstrapWebRuntimeEnv(); + const redirect = new URL('/setup', webEnv.R_APP_URL); + redirect.searchParams.set('step', 'source-control-connect'); + const response = () => NextResponse.redirect(redirect); + const authResult = await authorize(); + const state = request.nextUrl.searchParams.get('state'); + const expectedState = request.cookies.get( + 'roomote-gitlab-oauth-state', + )?.value; + const code = request.nextUrl.searchParams.get('code'); + if ( + !authResult.success || + !authResult.isAdmin || + !state || + state !== expectedState || + !code + ) { + redirect.searchParams.set('gitlab', 'error'); + return response(); + } + try { + const [baseUrl, clientId, clientSecret] = await Promise.all([ + resolveGitLabBaseUrl(), + resolveDeploymentEnvVar('GITLAB_CLIENT_ID'), + resolveDeploymentEnvVar('GITLAB_CLIENT_SECRET'), + ]); + if (!clientId || !clientSecret) + throw new Error('GitLab OAuth client credentials are not configured.'); + await exchangeGitLabOAuthCode({ + baseUrl, + clientId, + clientSecret, + code, + redirectUri: buildGitLabOAuthRedirectUri(webEnv.R_APP_URL), + }); + redirect.searchParams.set('gitlab', 'connected'); + } catch (error) { + console.error('[GitLab OAuth] callback failed', error); + redirect.searchParams.set('gitlab', 'error'); + } + const result = response(); + result.cookies.set('roomote-gitlab-oauth-state', '', { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitlab/oauth', + maxAge: 0, + }); + return result; +} diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 604639a9..a8ce62ba 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -719,6 +719,16 @@ export async function assertValidSourceControlConfigInput(params: { params.provider === 'gitlab' ? params.values?.['GITLAB_TOKEN']?.trim() : undefined; + const nextGitLabClientId = + params.provider === 'gitlab' + ? (params.values?.['GITLAB_CLIENT_ID']?.trim() ?? + (await resolveDeploymentEnvVar('GITLAB_CLIENT_ID'))) + : undefined; + const nextGitLabClientSecret = + params.provider === 'gitlab' + ? (params.values?.['GITLAB_CLIENT_SECRET']?.trim() ?? + (await resolveDeploymentEnvVar('GITLAB_CLIENT_SECRET'))) + : undefined; const nextGiteaToken = params.provider === 'gitea' ? params.values?.['GITEA_TOKEN']?.trim() @@ -791,6 +801,16 @@ export async function assertValidSourceControlConfigInput(params: { } } + if ( + params.provider === 'gitlab' && + !nextGitLabToken && + !(nextGitLabClientId && nextGitLabClientSecret) + ) { + throw new Error( + 'Configure the GitLab OAuth client ID and secret, or use the advanced PAT migration path.', + ); + } + if (nextGiteaToken) { const nextGiteaBaseUrl = (params.values?.['GITEA_BASE_URL']?.trim() diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 7cc6f374..b7fbdfbf 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -15,6 +15,10 @@ import { inArray, resolveDeploymentEnvVar, } from '@roomote/db/server'; +import { + isGitLabOAuthAccessToken, + resolveGitLabOAuthAccessToken, +} from './oauth'; const GITLAB_PROVIDER = 'gitlab' satisfies SourceControlProvider; const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; @@ -130,7 +134,8 @@ export function normalizeGitLabBaseUrl(baseUrl: string): string { } export async function resolveGitLabToken(): Promise { - return resolveDeploymentEnvVar('GITLAB_TOKEN'); + const oauthToken = await resolveGitLabOAuthAccessToken(); + return oauthToken ?? (await resolveDeploymentEnvVar('GITLAB_TOKEN')); } let cachedGitLabDeploymentUser: { @@ -301,7 +306,9 @@ async function requestGitLab( method, headers: { Accept: 'application/json', - 'PRIVATE-TOKEN': token, + ...(isGitLabOAuthAccessToken(token) + ? { Authorization: `Bearer ${token}` } + : { 'PRIVATE-TOKEN': token }), ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), @@ -1146,6 +1153,26 @@ export async function createTaskRunScopedGitLabTokens( const apiBaseUrl = options?.apiBaseUrl ?? buildGitLabApiBaseUrl(baseUrl); const host = hostFromBaseUrl(baseUrl); const repositoriesList = await resolveGitLabRepositoryRowsForTaskRun(taskRun); + + // OAuth grants are deployment-scoped and already carry the repository + // permissions needed by the worker. Do not attempt to mint GitLab project + // tokens with an OAuth access token; this also keeps the refresh token out + // of task artifacts and preserves the PAT-only isolation path below. + if (isGitLabOAuthAccessToken(deploymentToken)) { + return { + credentials: [], + proxyCredentials: repositoriesList.map((repository) => ({ + host, + repositoryFullName: repository.repositoryFullName, + username: 'oauth2', + token: deploymentToken, + })), + artifactsPatch: { + [GITLAB_SCOPED_PROJECT_TOKENS_ARTIFACT_KEY]: [], + }, + }; + } + const persistedDescriptors = new Map( readScopedProjectTokenDescriptors(taskRun.artifacts).map((descriptor) => [ descriptor.repositoryFullName, diff --git a/packages/gitlab/src/index.ts b/packages/gitlab/src/index.ts index b1c13e73..18ac6e09 100644 --- a/packages/gitlab/src/index.ts +++ b/packages/gitlab/src/index.ts @@ -1 +1,2 @@ export * from './api'; +export * from './oauth'; diff --git a/packages/gitlab/src/oauth.ts b/packages/gitlab/src/oauth.ts new file mode 100644 index 00000000..6221d16e --- /dev/null +++ b/packages/gitlab/src/oauth.ts @@ -0,0 +1,239 @@ +import { randomBytes } from 'node:crypto'; + +import { db, deploymentSecrets, eq } from '@roomote/db/server'; +import { decryptSecrets, encryptJSON } from '@roomote/db/encryption'; + +const SECRET_NAME = 'gitlab_deployment_oauth_connection'; +const DEFAULT_SCOPES = ['api', 'read_repository', 'write_repository'] as const; + +export type GitLabOAuthConnectionStatus = 'active' | 'reauthorization_required'; + +export type GitLabOAuthConnection = { + baseUrl: string; + clientId: string; + clientSecret: string; + accountId: string; + username: string; + accessToken: string; + refreshToken: string; + expiresAt: string; + scopes: string[]; + status: GitLabOAuthConnectionStatus; +}; + +type GitLabOAuthTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + created_at?: number; + scope?: string; +}; + +let refreshPromise: Promise | null = null; +let cachedAccessToken: string | null = null; + +function tokenEndpoint(baseUrl: string): string { + return new URL('oauth/token', `${baseUrl.replace(/\/$/, '')}/`).toString(); +} + +export function getGitLabOAuthScopes(): readonly string[] { + return DEFAULT_SCOPES; +} + +export function buildGitLabOAuthRedirectUri(appUrl: string): string { + return new URL( + '/api/source-control/gitlab/oauth/callback', + `${appUrl.replace(/\/$/, '')}/`, + ).toString(); +} + +export function createGitLabOAuthAuthorizationUrl(input: { + baseUrl: string; + clientId: string; + redirectUri: string; + state?: string; +}): { url: string; state: string } { + const state = input.state ?? randomBytes(32).toString('hex'); + const url = new URL( + '/oauth/authorize', + `${input.baseUrl.replace(/\/$/, '')}/`, + ); + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', input.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', state); + url.searchParams.set('scope', DEFAULT_SCOPES.join(' ')); + return { url: url.toString(), state }; +} + +async function readConnection(): Promise { + const findFirst = db.query.deploymentSecrets?.findFirst; + // Older test harnesses and pre-migration deployments may not expose the + // deployment-secrets relation yet; PAT resolution remains available there. + if (!findFirst) return null; + const row = await findFirst({ + where: eq(deploymentSecrets.name, SECRET_NAME), + }); + return row ? await decryptSecrets(row.value) : null; +} + +async function writeConnection( + connection: GitLabOAuthConnection, +): Promise { + const value = encryptJSON(connection); + await db + .insert(deploymentSecrets) + .values({ name: SECRET_NAME, value }) + .onConflictDoUpdate({ + target: deploymentSecrets.name, + set: { value, updatedAt: new Date() }, + }); +} + +export async function getGitLabOAuthConnection(): Promise { + return readConnection(); +} + +export async function exchangeGitLabOAuthCode(input: { + baseUrl: string; + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; + fetchImpl?: typeof fetch; +}): Promise { + const response = await (input.fetchImpl ?? fetch)( + tokenEndpoint(input.baseUrl), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + grant_type: 'authorization_code', + redirect_uri: input.redirectUri, + }), + }, + ); + if (!response.ok) + throw new Error( + `GitLab OAuth token exchange failed: ${response.status} ${response.statusText}`, + ); + const token = (await response.json()) as GitLabOAuthTokenResponse; + if (!token.access_token || !token.refresh_token) + throw new Error('GitLab OAuth did not return an access and refresh token.'); + + const connection: GitLabOAuthConnection = { + baseUrl: input.baseUrl, + clientId: input.clientId, + clientSecret: input.clientSecret, + accountId: '', + username: '', + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: new Date( + Date.now() + (token.expires_in ?? 7200) * 1000, + ).toISOString(), + scopes: token.scope?.split(/\s+/).filter(Boolean) ?? [...DEFAULT_SCOPES], + status: 'active', + }; + try { + const userResponse = await (input.fetchImpl ?? fetch)( + new URL('api/v4/user', `${input.baseUrl.replace(/\/$/, '')}/`), + { headers: { Authorization: `Bearer ${connection.accessToken}` } }, + ); + if (userResponse.ok) { + const user = (await userResponse.json()) as { + id?: number; + username?: string; + }; + connection.accountId = user.id === undefined ? '' : String(user.id); + connection.username = user.username ?? ''; + } + } catch { + // Token exchange is still valid when the identity lookup is temporarily unavailable. + } + await writeConnection(connection); + cachedAccessToken = connection.accessToken; + return connection; +} + +export async function resolveGitLabOAuthAccessToken(options?: { + fetchImpl?: typeof fetch; + forceRefresh?: boolean; +}): Promise { + const connection = await readConnection(); + if (!connection || connection.status !== 'active') return null; + if ( + !options?.forceRefresh && + Date.parse(connection.expiresAt) > Date.now() + 60_000 + ) { + cachedAccessToken = connection.accessToken; + return connection.accessToken; + } + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + const response = await (options?.fetchImpl ?? fetch)( + tokenEndpoint(connection.baseUrl), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: connection.clientId, + client_secret: connection.clientSecret, + refresh_token: connection.refreshToken, + grant_type: 'refresh_token', + }), + }, + ); + if (!response.ok) { + await writeConnection({ + ...connection, + status: 'reauthorization_required', + }); + throw new Error( + 'GitLab OAuth authorization has expired and must be renewed.', + ); + } + const token = (await response.json()) as GitLabOAuthTokenResponse; + const next = { + ...connection, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? connection.refreshToken, + expiresAt: new Date( + Date.now() + (token.expires_in ?? 7200) * 1000, + ).toISOString(), + scopes: token.scope?.split(/\s+/).filter(Boolean) ?? connection.scopes, + status: 'active' as const, + }; + await writeConnection(next); + cachedAccessToken = next.accessToken; + return next.accessToken; + })(); + try { + return await refreshPromise; + } finally { + refreshPromise = null; + } +} + +export function isGitLabOAuthAccessToken(token: string): boolean { + return token === cachedAccessToken; +} + +export async function markGitLabOAuthReauthorizationRequired(): Promise { + const connection = await readConnection(); + if (connection) + await writeConnection({ + ...connection, + status: 'reauthorization_required', + }); +} diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index c666068a..03c933bf 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -245,6 +245,8 @@ function buildProviderFields( acceptedEnvVarNames: ['GITLAB_TOKEN'], label: 'GitLab Automation Token', secret: true, + required: false, + advanced: true, }, { envVarName: 'GITLAB_BASE_URL', @@ -257,7 +259,6 @@ function buildProviderFields( acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], label: 'GitLab OAuth Client ID', required: false, - setupHidden: true, }, { envVarName: 'GITLAB_CLIENT_SECRET', @@ -265,7 +266,6 @@ function buildProviderFields( label: 'GitLab OAuth Client Secret', secret: true, required: false, - setupHidden: true, }, { envVarName: 'GITLAB_WEBHOOK_SIGNING_TOKEN', From 878e9f5284193f5ada127d96423886d3e876e0cb Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 13:58:24 +0100 Subject: [PATCH 10/31] feat(gitea): make OAuth the primary source-control connection --- .env.production.example | 3 +- apps/docs/environment-variables.mdx | 75 +++--- apps/docs/providers/source-control/gitea.mdx | 32 +-- apps/docs/providers/source-control/gitlab.mdx | 73 ++---- .../StepSourceControlConfig.client.test.tsx | 42 +--- .../setup/StepSourceControlConfig.tsx | 51 +++- .../setup/StepSourceControlConnect.tsx | 24 ++ .../setup/sourceControlSetupCopy.ts | 7 +- .../gitea/oauth/authorize/route.ts | 46 ++++ .../gitea/oauth/callback/route.ts | 64 +++++ .../settings/SourceControl.test.tsx | 4 +- .../src/trpc/commands/source-control/index.ts | 59 ++--- deploy/compose/docker-compose.prod.yml | 6 +- packages/gitea/src/__tests__/api.test.ts | 68 +---- packages/gitea/src/__tests__/oauth.test.ts | 39 +++ packages/gitea/src/api.ts | 78 ++---- packages/gitea/src/index.ts | 1 + packages/gitea/src/oauth.ts | 232 ++++++++++++++++++ packages/gitlab/src/__tests__/api.test.ts | 30 +-- packages/gitlab/src/api.ts | 21 +- .../src/setup-source-control-config.test.ts | 75 ++++-- .../types/src/setup-source-control-config.ts | 38 +-- 22 files changed, 682 insertions(+), 386 deletions(-) create mode 100644 apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts create mode 100644 apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts create mode 100644 packages/gitea/src/__tests__/oauth.test.ts create mode 100644 packages/gitea/src/oauth.ts diff --git a/.env.production.example b/.env.production.example index e3b93e18..fb596f06 100644 --- a/.env.production.example +++ b/.env.production.example @@ -125,7 +125,8 @@ DEFAULT_COMPUTE_PROVIDER=docker # R_GITHUB_CLIENT_ID= # R_GITHUB_CLIENT_SECRET= # R_GITHUB_WEBHOOK_SECRET= -# GITLAB_TOKEN= +# GITLAB_CLIENT_ID= +# GITLAB_CLIENT_SECRET= # GITLAB_WEBHOOK_SECRET= # GITLAB_WEBHOOK_SIGNING_TOKEN= diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 8ddd5a16..5f91e4c5 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -236,45 +236,42 @@ as per-task auth tokens or workspace paths. ### Source control providers -| Env var | Required | Used for | -| ------------------------------ | ------------ | ------------------------------------------------------------------------------------------------ | -| `R_GITHUB_APP_SLUG` | GitHub | GitHub App slug. | -| `R_GITHUB_APP_ID` | GitHub | GitHub App ID. | -| `R_GITHUB_APP_PRIVATE_KEY` | GitHub | Raw GitHub App private key PEM, usually with newlines escaped as `\\n`. | -| `R_GITHUB_CLIENT_ID` | GitHub | GitHub OAuth client ID. | -| `R_GITHUB_CLIENT_SECRET` | GitHub | GitHub OAuth client secret. | -| `R_GITHUB_WEBHOOK_SECRET` | GitHub | GitHub webhook secret. | -| `GITHUB_MCP_SERVER_URL` | Optional | GitHub MCP server URL override. | -| `GITHUB_AUTOMATED_SKIP_REPOS` | Optional | Repository skip list for automated GitHub processing. | -| `GITHUB_AUTOMATED_SKIP_OWNERS` | Optional | Owner skip list for automated GitHub processing. | -| `GITLAB_TOKEN` | GitLab | GitLab automation token for source-control setup; prefer a dedicated bot/service identity over a personal user account. | -| `GITLAB_BASE_URL` | Optional | GitLab base URL for self-managed GitLab. | -| `GITLAB_CLIENT_ID` | Optional | GitLab OAuth application ID for personal account linking and merge request comment triggers. | -| `GITLAB_CLIENT_SECRET` | Optional | GitLab OAuth application secret for personal account linking and merge request comment triggers. | -| `GITLAB_WEBHOOK_SIGNING_TOKEN` | Optional | GitLab webhook signing token. | -| `GITLAB_WEBHOOK_SECRET` | Optional | GitLab webhook secret. | -| `GITEA_BASE_URL` | Gitea | Gitea base URL. | -| `GITEA_TOKEN` | Gitea | Gitea access token. | -| `GITEA_USERNAME` | Optional | Gitea username. | -| `GITEA_CLIENT_ID` | Optional | Gitea OAuth client ID. | -| `GITEA_CLIENT_SECRET` | Optional | Gitea OAuth client secret. | -| `GITEA_WEBHOOK_SECRET` | Optional | Gitea webhook secret. | -| `BITBUCKET_TOKEN` | Bitbucket | Atlassian API token with Bitbucket scopes. | -| `BITBUCKET_USERNAME` | Bitbucket | Atlassian account email that owns the API token. | -| `BITBUCKET_BASE_URL` | Optional | Bitbucket base URL. Defaults to `https://bitbucket.org` (Cloud only). | -| `BITBUCKET_CLIENT_ID` | Optional | Bitbucket OAuth client ID for personal account linking. | -| `BITBUCKET_CLIENT_SECRET` | Optional | Bitbucket OAuth client secret for personal account linking. | -| `BITBUCKET_WEBHOOK_SECRET` | Optional | Bitbucket webhook secret. | -| `ADO_ORGANIZATION` | Azure DevOps | Azure DevOps organization. | -| `ADO_TOKEN` | Azure DevOps | Azure DevOps access token. | -| `ADO_AUTH_MODE` | Optional | `pat`, `entra`, or `delegated`; omitted values retain legacy PAT/Entra inference. | -| `ADO_LINKED_ACCOUNT_ID` | Optional | Linked Azure DevOps account key used by delegated authentication. | -| `ADO_BASE_URL` | Optional | Azure DevOps base URL. | -| `ADO_USERNAME` | Optional | Azure DevOps username. | -| `ADO_CLIENT_ID` | Optional | Microsoft Entra client ID for Azure DevOps service-principal authentication. | -| `ADO_CLIENT_SECRET` | Optional | Microsoft Entra client secret for Azure DevOps service-principal authentication. | -| `ADO_TENANT_ID` | Optional | Microsoft Entra tenant ID for Azure DevOps service-principal authentication. | -| `ADO_WEBHOOK_SECRET` | Optional | Azure DevOps webhook secret. Generated automatically when Roomote configures service hooks. | +| Env var | Required | Used for | +| ------------------------------ | ------------ | ------------------------------------------------------------------------------------------- | +| `R_GITHUB_APP_SLUG` | GitHub | GitHub App slug. | +| `R_GITHUB_APP_ID` | GitHub | GitHub App ID. | +| `R_GITHUB_APP_PRIVATE_KEY` | GitHub | Raw GitHub App private key PEM, usually with newlines escaped as `\\n`. | +| `R_GITHUB_CLIENT_ID` | GitHub | GitHub OAuth client ID. | +| `R_GITHUB_CLIENT_SECRET` | GitHub | GitHub OAuth client secret. | +| `R_GITHUB_WEBHOOK_SECRET` | GitHub | GitHub webhook secret. | +| `GITHUB_MCP_SERVER_URL` | Optional | GitHub MCP server URL override. | +| `GITHUB_AUTOMATED_SKIP_REPOS` | Optional | Repository skip list for automated GitHub processing. | +| `GITHUB_AUTOMATED_SKIP_OWNERS` | Optional | Owner skip list for automated GitHub processing. | +| `GITLAB_BASE_URL` | Optional | GitLab base URL for self-managed GitLab. | +| `GITLAB_CLIENT_ID` | GitLab | GitLab OAuth application ID. | +| `GITLAB_CLIENT_SECRET` | GitLab | GitLab OAuth application secret. | +| `GITLAB_WEBHOOK_SIGNING_TOKEN` | Optional | GitLab webhook signing token. | +| `GITLAB_WEBHOOK_SECRET` | Optional | GitLab webhook secret. | +| `GITEA_BASE_URL` | Gitea | Gitea base URL. | +| `GITEA_CLIENT_ID` | Gitea | Gitea OAuth application client ID for deployment and personal account-linking grants. | +| `GITEA_CLIENT_SECRET` | Gitea | Gitea OAuth application client secret. | +| `GITEA_WEBHOOK_SECRET` | Optional | Gitea webhook secret. | +| `BITBUCKET_TOKEN` | Bitbucket | Atlassian API token with Bitbucket scopes. | +| `BITBUCKET_USERNAME` | Bitbucket | Atlassian account email that owns the API token. | +| `BITBUCKET_BASE_URL` | Optional | Bitbucket base URL. Defaults to `https://bitbucket.org` (Cloud only). | +| `BITBUCKET_CLIENT_ID` | Optional | Bitbucket OAuth client ID for personal account linking. | +| `BITBUCKET_CLIENT_SECRET` | Optional | Bitbucket OAuth client secret for personal account linking. | +| `BITBUCKET_WEBHOOK_SECRET` | Optional | Bitbucket webhook secret. | +| `ADO_ORGANIZATION` | Azure DevOps | Azure DevOps organization. | +| `ADO_TOKEN` | Azure DevOps | Azure DevOps access token. | +| `ADO_AUTH_MODE` | Optional | `pat`, `entra`, or `delegated`; omitted values retain legacy PAT/Entra inference. | +| `ADO_LINKED_ACCOUNT_ID` | Optional | Linked Azure DevOps account key used by delegated authentication. | +| `ADO_BASE_URL` | Optional | Azure DevOps base URL. | +| `ADO_USERNAME` | Optional | Azure DevOps username. | +| `ADO_CLIENT_ID` | Optional | Microsoft Entra client ID for Azure DevOps service-principal authentication. | +| `ADO_CLIENT_SECRET` | Optional | Microsoft Entra client secret for Azure DevOps service-principal authentication. | +| `ADO_TENANT_ID` | Optional | Microsoft Entra tenant ID for Azure DevOps service-principal authentication. | +| `ADO_WEBHOOK_SECRET` | Optional | Azure DevOps webhook secret. Generated automatically when Roomote configures service hooks. | ### Communications and sign-in diff --git a/apps/docs/providers/source-control/gitea.mdx b/apps/docs/providers/source-control/gitea.mdx index fd08be04..6fc47649 100644 --- a/apps/docs/providers/source-control/gitea.mdx +++ b/apps/docs/providers/source-control/gitea.mdx @@ -17,9 +17,11 @@ Use `` below for your stable public Roomote URL. ## Create a Gitea bot token Create a dedicated Gitea bot or service-account user and give it access to the -organizations or repositories Roomote should use. Create an access token with -repository read/write permissions appropriate for cloning, branch creation, and -commits. +organizations or repositories Roomote should use. New deployments use a Gitea +1.23+ OAuth application and a dedicated service account. Create the application +with the redirect URI shown by Roomote and authorize it with the scopes +`read:user`, `read:repository`, `write:repository`, `write:issue`, and +`read:organization`. If Roomote should configure webhooks automatically, the token identity also needs repository admin access for the synced repositories. @@ -29,22 +31,22 @@ Save the instance URL and token in setup, deployment env vars, or local ```sh GITEA_BASE_URL=https://git.example.com -GITEA_TOKEN= +GITEA_CLIENT_ID= +GITEA_CLIENT_SECRET= ``` Optional values: ```sh -GITEA_USERNAME= GITEA_WEBHOOK_SECRET= ``` -When `GITEA_USERNAME` is omitted, Roomote resolves the token owner through the -Gitea API and uses that login for Git-over-HTTPS credentials. +Roomote runs deployment operations as the authorized service account and uses +that login for Git-over-HTTPS credentials. -The setup flow asks only for the Gitea instance URL and access token. Roomote -generates the webhook secret during repository sync; optional username and OAuth -settings remain available under Settings. +The setup flow asks for the Gitea instance URL and OAuth application +credentials, then sends the operator through the authorization flow. Roomote +generates the webhook secret during repository sync. ## Sync repositories @@ -54,8 +56,8 @@ repositories from the Gitea API and stores them as Gitea repository rows. Gitea-backed tasks clone from the synced repository row, so sync must run before launching a Gitea-backed task. Worker tasks route selected Gitea HTTPS -clone traffic through a local proxy instead of exporting `GITEA_TOKEN` into -task shells. +clone traffic through a local proxy instead of exposing OAuth tokens to task +shells. After repository sync, Roomote best-effort creates or refreshes a repository webhook for each synced Gitea repository. The webhook target is: @@ -80,9 +82,9 @@ existing PR owner task when possible, or enqueue a new review task. ## Current limits -Gitea does not yet have a self-serve OAuth/App installation flow, personal -linked-account flow, or repository permission delegation UI. Gitea support is -deployment-token backed. +Personal Gitea account linking reuses the same OAuth application but stores a +separate `authAccounts` grant with only `read:user`; the deployment service +account grant is never used to identify a pull-request commenter. ## Verify setup diff --git a/apps/docs/providers/source-control/gitlab.mdx b/apps/docs/providers/source-control/gitlab.mdx index 2b6e92b2..09d3320f 100644 --- a/apps/docs/providers/source-control/gitlab.mdx +++ b/apps/docs/providers/source-control/gitlab.mdx @@ -4,48 +4,37 @@ icon: 'https://api.iconify.design/simple-icons:gitlab.svg?color=currentColor' description: Configure GitLab repository sync and merge request webhooks for Roomote. --- -GitLab does not have a GitHub-style App installation flow. GitLab OAuth -applications are for delegated user access, not for granting Roomote an -independent installation identity. Roomote therefore uses a deployment-owned -automation token for repository sync, task execution, merge-request notes, and -webhook management. Use a dedicated bot or service identity rather than a -personal developer account. +GitLab does not have a GitHub-style App installation flow. Configure a GitLab +OAuth application for Roomote's deployment, then authorize it once so Roomote +can sync repositories, run tasks, add merge-request notes, and manage webhooks. Use `` below for your stable public Roomote URL. -## Create a GitLab automation token +## Create a GitLab OAuth application -Add a dedicated GitLab bot or service-account identity to the groups or projects -Roomote should access. Prefer a group or project access token when it fits your -GitLab plan and repository layout. If Roomote needs to enumerate repositories -across memberships or create short-lived project tokens, create a personal -access token for the dedicated bot account instead—not for an individual -operator. Give the token scopes that match the behavior you want: +Create the application on the GitLab account, group, or instance that should +own Roomote's access. Use these settings: -| Scope | Needed for | -| ------------------ | ------------------------------------------------ | -| `api` | project listing and repository sync through REST | -| `read_repository` | cloning private repositories over Git-over-HTTPS | -| `write_repository` | pushing branches or commits from Roomote tasks | +- **Confidential**: enabled +- **Redirect URI**: `/api/source-control/gitlab/oauth/callback` +- **Scopes**: `api` -Save the token in setup, deployment env vars, or local `.env.local`: +Save the application credentials in setup, deployment env vars, or local +`.env.local`: ```sh -GITLAB_TOKEN= +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= ``` For self-managed GitLab, also set `GITLAB_BASE_URL`. -The setup flow asks only for the automation token and, when needed, the -self-managed GitLab base URL. OAuth is configured separately and is optional; -it is only needed when teammates should link their GitLab accounts for -merge-request comment triggers. - ## Sync repositories -After the token is available, open Settings, go to the Environments page, and -use the Source Control section's GitLab sync button. Roomote lists the GitLab -projects visible to the token and stores them as GitLab repository rows. +After the application credentials are configured, open Settings, go to the +Environments page, authorize GitLab, and use the Source Control section's sync +button. Roomote lists the projects visible to the authorized GitLab account and +stores them as GitLab repository rows. GitLab-backed tasks clone from the synced repository row, so sync must run before launching a GitLab-backed task. @@ -79,31 +68,9 @@ GITLAB_WEBHOOK_SECRET= ## Enable merge request comment triggers (recommended) -Users can trigger Roomote tasks by mentioning `@roomote` in merge request -comments, but only after linking their personal GitLab account in Roomote's -Personal settings. This is a separate, optional GitLab OAuth application: - -1. Open **Applications** in GitLab — on the bot account - (`/-/user_settings/applications`), a group, or the admin area for a - self-managed instance. -2. Create an application with: - - **Name**: `Roomote` (upload the Roomote logo if you want) - - **Redirect URI**: `/api/auth/oauth2/callback/gitlab` - - **Confidential**: enabled - - **Scopes**: `read_user` only -3. Save the generated Application ID and Secret in setup, deployment env vars, - or local `.env.local`: - -```sh -GITLAB_CLIENT_ID= -GITLAB_CLIENT_SECRET= -``` - -Once configured, a GitLab row appears under Personal settings > Linked -Accounts, and users who link their account can trigger tasks from merge -request comments. Without these credentials, automatic merge request review -still works, but `@roomote` comment mentions reply with account-linking -instructions instead of starting work. +Once the application is configured, users can link their personal GitLab +accounts in Roomote's Personal settings and trigger tasks by mentioning +`@roomote` in merge request comments. ## Enable review automation diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 644c95e5..c56b861b 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -112,10 +112,9 @@ function buildSourceControlSetup( repositoryCount: 0, fields: [ { - envVarName: 'GITLAB_TOKEN', - acceptedEnvVarNames: ['GITLAB_TOKEN'], - label: 'GitLab Automation Token', - secret: true, + envVarName: 'GITLAB_CLIENT_ID', + acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], + label: 'GitLab OAuth Client ID', runtimeSatisfied: false, savedSatisfied: false, satisfiedByEnvVarName: null, @@ -239,7 +238,7 @@ describe('StepSourceControlConfig', () => { ).toBeInTheDocument(); }); - it('guides GitLab automation token setup', () => { + it('guides GitLab OAuth application setup', () => { render( { />, ); - expect(screen.getByText('GitLab Automation Token')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute( - 'href', - 'https://gitlab.com/-/user_settings/personal_access_tokens', - ); - expect( - screen.getByText( - /avatar → Edit profile → Access → Personal access tokens/, - ), - ).toBeInTheDocument(); - expect( - screen.getByText(/OAuth application setup is separate and optional/), - ).toBeInTheDocument(); expect( - screen.queryByText(/Recommended: create a GitLab OAuth application/), - ).not.toBeInTheDocument(); + screen.getAllByText(/GitLab OAuth application/).length, + ).toBeGreaterThan(0); + expect(screen.getByText(/OAuth application in GitLab/)).toBeInTheDocument(); expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Create GitHub App' }), ).not.toBeInTheDocument(); }); - it('guides Gitea token creation without optional credentials', () => { + it('guides Gitea OAuth application setup', () => { render( { ); expect(screen.getByText('Gitea Base URL')).toBeInTheDocument(); - expect(screen.getByText('Gitea Access Token')).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: /View Gitea guide/ }), - ).toHaveAttribute('href', 'https://docs.gitea.com/development/api-usage'); - expect( - screen.getByText(/Settings → Applications → Manage Access Tokens/), - ).toBeInTheDocument(); + expect(screen.getByText(/Gitea OAuth Client ID/)).toBeInTheDocument(); + expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); + expect(screen.getByText(/Gitea 1\.23 or newer/)).toBeInTheDocument(); + expect(screen.getByText(/read:user, read:repository/)).toBeInTheDocument(); + expect(screen.queryByText('Gitea Access Token')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); - expect(screen.queryByText('Gitea OAuth Client ID')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 9bb43820..cf3276b6 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -118,6 +118,7 @@ export function StepSourceControlConfig({ const [showAdoAdvancedConfig, setShowAdoAdvancedConfig] = useState(false); const [showGitlabAdvancedConfig, setShowGitlabAdvancedConfig] = useState(false); + const [showGiteaAdvancedConfig, setShowGiteaAdvancedConfig] = useState(false); const [adoAuthMode, setAdoAuthMode] = useState<'pat' | 'entra' | 'delegated'>( DEFAULT_ADO_AUTH_MODE, ); @@ -172,7 +173,9 @@ export function StepSourceControlConfig({ getSetupSourceControlVisibleFields(providerFields, { showAdvancedConfig: (isAdo && showAdoAdvancedConfig) || - (selectedProvider?.provider === 'gitlab' && showGitlabAdvancedConfig), + (selectedProvider?.provider === 'gitlab' && + showGitlabAdvancedConfig) || + (selectedProvider?.provider === 'gitea' && showGiteaAdvancedConfig), }).filter((field) => !isAdo ? true @@ -182,7 +185,15 @@ export function StepSourceControlConfig({ field.envVarName !== 'ADO_TENANT_ID' : field.envVarName !== 'ADO_TOKEN', ), - [providerFields, isAdo, showAdoAdvancedConfig, adoAuthMode], + [ + providerFields, + isAdo, + showAdoAdvancedConfig, + showGitlabAdvancedConfig, + showGiteaAdvancedConfig, + adoAuthMode, + selectedProvider?.provider, + ], ); // Key off field content, not array identity — parent refreshes create a new @@ -210,6 +221,7 @@ export function StepSourceControlConfig({ setShowManualGitHubValues(false); setShowAdoAdvancedConfig(false); setShowGitlabAdvancedConfig(false); + setShowGiteaAdvancedConfig(false); const hasAdoEntraCredentials = providerFields.some( (field) => ['ADO_CLIENT_ID', 'ADO_CLIENT_SECRET', 'ADO_TENANT_ID'].includes( @@ -318,7 +330,7 @@ export function StepSourceControlConfig({ ); const creationHref = selectedProvider?.provider === 'gitlab' - ? `${effectiveGitLabBaseUrl}/-/user_settings/personal_access_tokens` + ? `${effectiveGitLabBaseUrl}/-/user_settings/applications` : providerSetupCopy?.creationHref; if (selectedProvider?.provider === 'github' && !showManualGitHubValues) { @@ -418,6 +430,16 @@ export function StepSourceControlConfig({ the application credentials.

)} + {selectedProvider?.provider === 'gitea' && ( +

+ OAuth redirect URI:{' '} + + {publicOrigin}/api/source-control/gitea/oauth/callback + + . Create the application in Gitea 1.23 or newer, then authorize it + with the dedicated service account. +

+ )} {(isAdo ? baseFields : visibleFields).map((field) => ( ))} - {(isAdo || selectedProvider?.provider === 'gitlab') && + {(isAdo || + selectedProvider?.provider === 'gitlab' || + selectedProvider?.provider === 'gitea') && advancedFields.length > 0 ? (
) : null} - {(isAdo ? showAdoAdvancedConfig : showGitlabAdvancedConfig) && - advancedFields.length > 0 + {(isAdo + ? showAdoAdvancedConfig + : selectedProvider?.provider === 'gitlab' + ? showGitlabAdvancedConfig + : showGiteaAdvancedConfig) && advancedFields.length > 0 ? advancedFields.map((field) => ( + field.envVarName === 'GITEA_CLIENT_ID' && + (field.runtimeSatisfied || field.savedSatisfied), + ) && + providerStatus?.fields.some( + (field) => + field.envVarName === 'GITEA_CLIENT_SECRET' && + (field.runtimeSatisfied || field.savedSatisfied), + ); const handleSyncRepositories = async () => { if ( @@ -289,6 +301,18 @@ export function StepSourceControlConnect({

) : null} + {giteaOAuthConfigured ? ( +

+ Authorize the Gitea application with the dedicated service account + before syncing repositories. + + Authorize Gitea + +

+ ) : null} {syncedWithZeroRepos ? (

No repositories were found. Check your token permissions and base diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index 3f10c32d..2545eecd 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -18,14 +18,15 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { + creationHref: 'https://gitlab.com/-/user_settings/applications', setupLabel: 'GitLab OAuth application', creationHint: - 'Create an OAuth application in GitLab with api, read_repository, and write_repository scopes. Use the exact redirect URI shown by Roomote, then authorize it with the dedicated service account. Existing PAT deployments can use the advanced migration path.', + 'Create an OAuth application in GitLab with api scope. Use the exact redirect URI shown by Roomote, then authorize it with the dedicated service account.', }, gitea: { - setupLabel: 'Gitea access token', + setupLabel: 'Gitea OAuth application', creationHint: - 'In your Gitea instance, go to your avatar → Settings → Applications → Manage Access Tokens → Generate Token. Give it at least read + write access to repositories.', + 'Create an OAuth application in Gitea 1.23+ with the exact redirect URI shown by Roomote. Authorize it with the dedicated service account and request read:user, read:repository, write:repository, write:issue, and read:organization scopes.', }, bitbucket: { creationHref: 'https://id.atlassian.com/manage-profile/security/api-tokens', diff --git a/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts new file mode 100644 index 00000000..120028e5 --- /dev/null +++ b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGiteaOAuthRedirectUri, + createGiteaOAuthAuthorizationUrl, + resolveGiteaBaseUrl, +} from '@roomote/gitea'; +import { authorize } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + const authResult = await authorize(); + const webEnv = await bootstrapWebRuntimeEnv(); + if (!authResult.success || !authResult.isAdmin) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + const [clientId, baseUrl] = await Promise.all([ + resolveDeploymentEnvVar('GITEA_CLIENT_ID'), + resolveGiteaBaseUrl(), + ]); + if (!clientId || !baseUrl) { + return NextResponse.json( + { error: 'Gitea OAuth client ID and base URL are not configured.' }, + { status: 400 }, + ); + } + const redirectUri = buildGiteaOAuthRedirectUri(webEnv.R_APP_URL); + const { url, state } = createGiteaOAuthAuthorizationUrl({ + baseUrl, + clientId, + redirectUri, + }); + const response = NextResponse.redirect(url); + response.cookies.set('roomote-gitea-oauth-state', state, { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitea/oauth', + maxAge: 600, + }); + return response; +} diff --git a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts new file mode 100644 index 00000000..4bb06c7b --- /dev/null +++ b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGiteaOAuthRedirectUri, + exchangeGiteaOAuthCode, + resolveGiteaBaseUrl, +} from '@roomote/gitea'; +import { authorize } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const webEnv = await bootstrapWebRuntimeEnv(); + const redirect = new URL('/setup', webEnv.R_APP_URL); + redirect.searchParams.set('step', 'source-control-connect'); + const response = () => NextResponse.redirect(redirect); + const authResult = await authorize(); + const state = request.nextUrl.searchParams.get('state'); + const expectedState = request.cookies.get('roomote-gitea-oauth-state')?.value; + const code = request.nextUrl.searchParams.get('code'); + if ( + !authResult.success || + !authResult.isAdmin || + !state || + state !== expectedState || + !code + ) { + redirect.searchParams.set('gitea', 'error'); + return response(); + } + try { + const [baseUrl, clientId, clientSecret] = await Promise.all([ + resolveGiteaBaseUrl(), + resolveDeploymentEnvVar('GITEA_CLIENT_ID'), + resolveDeploymentEnvVar('GITEA_CLIENT_SECRET'), + ]); + if (!baseUrl || !clientId || !clientSecret) { + throw new Error('Gitea OAuth client credentials are not configured.'); + } + await exchangeGiteaOAuthCode({ + baseUrl, + clientId, + clientSecret, + code, + redirectUri: buildGiteaOAuthRedirectUri(webEnv.R_APP_URL), + }); + redirect.searchParams.set('gitea', 'connected'); + } catch (error) { + console.error('[Gitea OAuth] callback failed', error); + redirect.searchParams.set('gitea', 'error'); + } + const result = response(); + result.cookies.set('roomote-gitea-oauth-state', '', { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitea/oauth', + maxAge: 0, + }); + return result; +} diff --git a/apps/web/src/components/settings/SourceControl.test.tsx b/apps/web/src/components/settings/SourceControl.test.tsx index 4dcec346..1d53ad2f 100644 --- a/apps/web/src/components/settings/SourceControl.test.tsx +++ b/apps/web/src/components/settings/SourceControl.test.tsx @@ -564,10 +564,10 @@ describe('SourceControl settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); expect( - screen.getByRole('link', { name: /GitLab automation token/ }), + screen.getByRole('link', { name: /GitLab OAuth application/ }), ).toHaveAttribute( 'href', - 'https://gitlab.com/-/user_settings/personal_access_tokens', + 'https://gitlab.com/-/user_settings/applications', ); expect( screen.getByTestId('source-control-config-gitlab'), diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index a8ce62ba..46a7787b 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -715,10 +715,6 @@ export async function assertValidSourceControlConfigInput(params: { values?: Partial>; allowIncompleteDelegated?: boolean; }): Promise { - const nextGitLabToken = - params.provider === 'gitlab' - ? params.values?.['GITLAB_TOKEN']?.trim() - : undefined; const nextGitLabClientId = params.provider === 'gitlab' ? (params.values?.['GITLAB_CLIENT_ID']?.trim() ?? @@ -729,9 +725,15 @@ export async function assertValidSourceControlConfigInput(params: { ? (params.values?.['GITLAB_CLIENT_SECRET']?.trim() ?? (await resolveDeploymentEnvVar('GITLAB_CLIENT_SECRET'))) : undefined; - const nextGiteaToken = + const nextGiteaClientId = + params.provider === 'gitea' + ? (params.values?.['GITEA_CLIENT_ID']?.trim() ?? + (await resolveDeploymentEnvVar('GITEA_CLIENT_ID'))) + : undefined; + const nextGiteaClientSecret = params.provider === 'gitea' - ? params.values?.['GITEA_TOKEN']?.trim() + ? (params.values?.['GITEA_CLIENT_SECRET']?.trim() ?? + (await resolveDeploymentEnvVar('GITEA_CLIENT_SECRET'))) : undefined; const nextBitbucketToken = params.provider === 'bitbucket' @@ -786,49 +788,20 @@ export async function assertValidSourceControlConfigInput(params: { ); } - if (nextGitLabToken) { - const nextGitLabBaseUrl = - (params.values?.['GITLAB_BASE_URL']?.trim() - ? GitLab.normalizeGitLabBaseUrl(params.values['GITLAB_BASE_URL']) - : undefined) ?? (await GitLab.resolveGitLabBaseUrl()); - const validation = await GitLab.validateGitLabToken({ - token: nextGitLabToken, - apiBaseUrl: GitLab.buildGitLabApiBaseUrl(nextGitLabBaseUrl), - }); - - if (validation.status === 'invalid') { - throw new Error(validation.error); - } - } - if ( params.provider === 'gitlab' && - !nextGitLabToken && !(nextGitLabClientId && nextGitLabClientSecret) ) { - throw new Error( - 'Configure the GitLab OAuth client ID and secret, or use the advanced PAT migration path.', - ); + throw new Error('Configure the GitLab OAuth client ID and secret.'); } - if (nextGiteaToken) { - const nextGiteaBaseUrl = - (params.values?.['GITEA_BASE_URL']?.trim() - ? Gitea.normalizeGiteaBaseUrl(params.values['GITEA_BASE_URL']) - : undefined) ?? (await Gitea.resolveGiteaBaseUrl()); - - if (!nextGiteaBaseUrl) { - return; - } - - const validation = await Gitea.validateGiteaToken({ - token: nextGiteaToken, - baseUrl: nextGiteaBaseUrl, - }); - - if (validation.status === 'invalid') { - throw new Error(validation.error); - } + if ( + params.provider === 'gitea' && + !(nextGiteaClientId && nextGiteaClientSecret) + ) { + throw new Error( + 'Configure the Gitea OAuth client ID and secret to continue.', + ); } if (nextBitbucketToken) { diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 48af02a6..20a8d630 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -78,7 +78,8 @@ x-roomote-web-env: &roomote-web-env R_GITHUB_CLIENT_ID: ${R_GITHUB_CLIENT_ID:-} R_GITHUB_CLIENT_SECRET: ${R_GITHUB_CLIENT_SECRET:-} R_GITHUB_WEBHOOK_SECRET: ${R_GITHUB_WEBHOOK_SECRET:-} - GITLAB_TOKEN: ${GITLAB_TOKEN:-} + GITLAB_CLIENT_ID: ${GITLAB_CLIENT_ID:-} + GITLAB_CLIENT_SECRET: ${GITLAB_CLIENT_SECRET:-} GITLAB_WEBHOOK_SECRET: ${GITLAB_WEBHOOK_SECRET:-} GITLAB_WEBHOOK_SIGNING_TOKEN: ${GITLAB_WEBHOOK_SIGNING_TOKEN:-} SLACK_APP_ID: ${SLACK_APP_ID:-} @@ -143,7 +144,8 @@ x-roomote-api-env: &roomote-api-env R_GITHUB_CLIENT_ID: ${R_GITHUB_CLIENT_ID:-} R_GITHUB_CLIENT_SECRET: ${R_GITHUB_CLIENT_SECRET:-} R_GITHUB_WEBHOOK_SECRET: ${R_GITHUB_WEBHOOK_SECRET:-} - GITLAB_TOKEN: ${GITLAB_TOKEN:-} + GITLAB_CLIENT_ID: ${GITLAB_CLIENT_ID:-} + GITLAB_CLIENT_SECRET: ${GITLAB_CLIENT_SECRET:-} GITLAB_WEBHOOK_SECRET: ${GITLAB_WEBHOOK_SECRET:-} GITLAB_WEBHOOK_SIGNING_TOKEN: ${GITLAB_WEBHOOK_SIGNING_TOKEN:-} SLACK_APP_ID: ${SLACK_APP_ID:-} diff --git a/packages/gitea/src/__tests__/api.test.ts b/packages/gitea/src/__tests__/api.test.ts index 51f4b1c7..8eb4faab 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -70,7 +70,6 @@ import { removeGiteaWebhooksForRepositories, getGiteaAuthenticatedUser, listGiteaRepositories, - validateGiteaToken, type GiteaRepository, } from '../api'; @@ -89,15 +88,11 @@ function makeTaskRun(payload: TaskRun['payload']): TaskRun { } describe('Gitea API helpers', () => { - const originalGiteaToken = process.env.GITEA_TOKEN; const originalGiteaBaseUrl = process.env.GITEA_BASE_URL; - const originalGiteaUsername = process.env.GITEA_USERNAME; beforeEach(() => { vi.clearAllMocks(); - process.env.GITEA_TOKEN = 'gitea_deployment_token'; process.env.GITEA_BASE_URL = 'https://git.example.com/'; - delete process.env.GITEA_USERNAME; mockEnvironmentVariablesFindMany.mockResolvedValue([]); mockEnvironmentsFindFirst.mockResolvedValue(null); mockRepositoriesFindMany.mockResolvedValue([ @@ -108,23 +103,11 @@ describe('Gitea API helpers', () => { }); afterEach(() => { - if (originalGiteaToken === undefined) { - delete process.env.GITEA_TOKEN; - } else { - process.env.GITEA_TOKEN = originalGiteaToken; - } - if (originalGiteaBaseUrl === undefined) { delete process.env.GITEA_BASE_URL; } else { process.env.GITEA_BASE_URL = originalGiteaBaseUrl; } - - if (originalGiteaUsername === undefined) { - delete process.env.GITEA_USERNAME; - } else { - process.env.GITEA_USERNAME = originalGiteaUsername; - } }); it('builds the API base URL from the configured Gitea instance URL', () => { @@ -203,7 +186,7 @@ describe('Gitea API helpers', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - Authorization: 'token gitea_test', + Authorization: 'Bearer gitea_test', }), }), ); @@ -215,7 +198,9 @@ describe('Gitea API helpers', () => { token: '', baseUrl: 'https://git.example.com', }), - ).rejects.toThrow('GITEA_TOKEN is required to sync Gitea repositories.'); + ).rejects.toThrow( + 'Gitea OAuth connection is required to sync repositories.', + ); await expect( listGiteaRepositories({ @@ -282,49 +267,12 @@ describe('Gitea API helpers', () => { 'https://git.example.com/api/v1/user', expect.objectContaining({ headers: expect.objectContaining({ - Authorization: 'token gitea_test', + Authorization: 'Bearer gitea_test', }), }), ); }); - it('validates a Gitea token with the authenticated user endpoint', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ login: 'roomote-bot' }), { - status: 200, - }), - ); - - await expect( - validateGiteaToken({ - token: 'gitea_test', - baseUrl: 'https://git.example.com', - fetchImpl: fetchMock, - }), - ).resolves.toEqual({ status: 'valid', login: 'roomote-bot' }); - }); - - it('rejects definitively invalid Gitea tokens during validation', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ message: 'forbidden' }), { - status: 403, - statusText: 'Forbidden', - }), - ); - - await expect( - validateGiteaToken({ - token: 'bad_token', - baseUrl: 'https://git.example.com', - fetchImpl: fetchMock, - }), - ).resolves.toEqual({ - status: 'invalid', - error: - 'Gitea rejected the token. Confirm the token is active and has repository access.', - }); - }); - it('removes the Roomote webhook from repositories and reports not_found when absent', async () => { const fetchMock = vi .fn() @@ -450,7 +398,7 @@ describe('Gitea API helpers', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - Authorization: 'token gitea_test', + Authorization: 'Bearer gitea_test', 'Content-Type': 'application/json', }), body: expect.stringContaining('"pull_request_sync"'), @@ -492,7 +440,7 @@ describe('Gitea API helpers', () => { description: 'Work on Gitea', sourceControlProvider: 'gitea', }), - { username: 'roomote-bot' }, + { username: 'roomote-bot', token: 'gitea_oauth_token' }, ); expect(result).toEqual({ @@ -501,7 +449,7 @@ describe('Gitea API helpers', () => { host: 'git.example.com', repositoryFullName: 'acme/backend', username: 'roomote-bot', - token: 'gitea_deployment_token', + token: 'gitea_oauth_token', originBaseUrl: 'https://git.example.com', }, ], diff --git a/packages/gitea/src/__tests__/oauth.test.ts b/packages/gitea/src/__tests__/oauth.test.ts new file mode 100644 index 00000000..aaa0af1e --- /dev/null +++ b/packages/gitea/src/__tests__/oauth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildGiteaOAuthRedirectUri, + createGiteaOAuthAuthorizationUrl, + getGiteaOAuthScopes, +} from '../oauth'; + +describe('Gitea deployment OAuth', () => { + it('centralizes the granular deployment scopes', () => { + expect(getGiteaOAuthScopes()).toEqual([ + 'read:user', + 'read:repository', + 'write:repository', + 'write:issue', + 'read:organization', + ]); + }); + + it('builds a self-managed callback and deployment-bound authorization state', () => { + const redirectUri = buildGiteaOAuthRedirectUri('https://roomote.example/'); + const result = createGiteaOAuthAuthorizationUrl({ + baseUrl: 'https://git.example/gitea', + clientId: 'client-id', + redirectUri, + state: 'deployment-state', + }); + const url = new URL(result.url); + + expect(redirectUri).toBe( + 'https://roomote.example/api/source-control/gitea/oauth/callback', + ); + expect(url.origin).toBe('https://git.example'); + expect(url.pathname).toBe('/login/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('client-id'); + expect(url.searchParams.get('state')).toBe('deployment-state'); + expect(url.searchParams.get('scope')).toBe(getGiteaOAuthScopes().join(' ')); + }); +}); diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 7296dc2a..c7ac0214 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -15,11 +15,11 @@ import { inArray, resolveDeploymentEnvVar, } from '@roomote/db/server'; +import { getGiteaOAuthConnection, resolveGiteaOAuthAccessToken } from './oauth'; const GITEA_PROVIDER = 'gitea' satisfies SourceControlProvider; const GITEA_REPOSITORIES_PER_PAGE = 50; const GITEA_WEBHOOK_ENSURE_CONCURRENCY = 5; -const GITEA_TOKEN_VALIDATION_TIMEOUT_MS = 10_000; const GITEA_WEBHOOK_EVENTS = [ 'pull_request', @@ -122,7 +122,7 @@ export function normalizeGiteaBaseUrl(baseUrl: string): string { } export async function resolveGiteaToken(): Promise { - return resolveDeploymentEnvVar('GITEA_TOKEN'); + return resolveGiteaOAuthAccessToken(); } let cachedGiteaDeploymentUser: { @@ -137,7 +137,7 @@ export async function resolveGiteaBaseUrl(): Promise { } export async function resolveGiteaUsername(): Promise { - return resolveDeploymentEnvVar('GITEA_USERNAME'); + return (await getGiteaOAuthConnection())?.username || null; } export function buildGiteaApiBaseUrl(baseUrl: string): string { @@ -184,7 +184,7 @@ async function requestGiteaJson({ method, headers: { Accept: 'application/json', - Authorization: `token ${token}`, + Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), @@ -226,54 +226,6 @@ export class GiteaApiError extends Error { } } -export type GiteaTokenValidationResult = - | { status: 'valid'; login: string } - | { status: 'invalid'; error: string } - | { status: 'unknown'; error: string }; - -export async function validateGiteaToken({ - token, - baseUrl, - apiBaseUrl, - fetchImpl = fetch, - timeoutMs = GITEA_TOKEN_VALIDATION_TIMEOUT_MS, -}: { - token: string; - baseUrl?: string; - apiBaseUrl?: string; - fetchImpl?: typeof fetch; - timeoutMs?: number; -}): Promise { - const timedFetch: typeof fetch = (input, init) => - fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }); - - try { - const { login } = await getGiteaAuthenticatedUser({ - token, - baseUrl, - apiBaseUrl, - fetchImpl: timedFetch, - }); - - return { status: 'valid', login }; - } catch (error) { - if (error instanceof GiteaApiError && [401, 403].includes(error.status)) { - return { - status: 'invalid', - error: - 'Gitea rejected the token. Confirm the token is active and has repository access.', - }; - } - - return { - status: 'unknown', - error: `Could not verify the Gitea token: ${ - error instanceof Error ? error.message : String(error) - }`, - }; - } -} - export async function listGiteaRepositories({ token, baseUrl, @@ -284,7 +236,7 @@ export async function listGiteaRepositories({ const giteaToken = token ?? (await resolveGiteaToken()); if (!giteaToken?.trim()) { - throw new Error('GITEA_TOKEN is required to sync Gitea repositories.'); + throw new Error('Gitea OAuth connection is required to sync repositories.'); } const resolvedBaseUrl = baseUrl ?? (await resolveGiteaBaseUrl()); @@ -584,7 +536,9 @@ export async function getGiteaAuthenticatedUser({ const giteaToken = token ?? (await resolveGiteaToken()); if (!giteaToken?.trim()) { - throw new Error('GITEA_TOKEN is required for Gitea source control jobs.'); + throw new Error( + 'Gitea OAuth connection is required for source control jobs.', + ); } const resolvedBaseUrl = baseUrl ?? (await resolveGiteaBaseUrl()); @@ -688,7 +642,7 @@ export async function createGiteaPullRequestComment({ if (!giteaToken?.trim()) { throw new Error( - 'GITEA_TOKEN is required to create Gitea pull request comments.', + 'Gitea OAuth connection is required to create pull request comments.', ); } @@ -860,7 +814,9 @@ export async function ensureGiteaWebhooksForRepositories({ const giteaToken = token ?? (await resolveGiteaToken()); if (!giteaToken?.trim()) { - throw new Error('GITEA_TOKEN is required to configure Gitea webhooks.'); + throw new Error( + 'Gitea OAuth connection is required to configure webhooks.', + ); } const resolvedBaseUrl = baseUrl ?? (await resolveGiteaBaseUrl()); @@ -957,7 +913,7 @@ async function removeGiteaRepositoryWebhook({ method: 'DELETE', headers: { Accept: 'application/json', - Authorization: `token ${token}`, + Authorization: `Bearer ${token}`, }, }, ); @@ -994,7 +950,9 @@ export async function removeGiteaWebhooksForRepositories({ const giteaToken = token ?? (await resolveGiteaToken()); if (!giteaToken?.trim()) { - throw new Error('GITEA_TOKEN is required to configure Gitea webhooks.'); + throw new Error( + 'Gitea OAuth connection is required to configure webhooks.', + ); } const resolvedBaseUrl = baseUrl ?? (await resolveGiteaBaseUrl()); @@ -1059,7 +1017,9 @@ export async function createTaskRunGiteaCredentials( const deploymentToken = options?.token ?? (await resolveGiteaToken()); if (!deploymentToken?.trim()) { - throw new Error('GITEA_TOKEN is required for Gitea source control jobs.'); + throw new Error( + 'Gitea OAuth connection is required for source control jobs.', + ); } const baseUrl = options?.baseUrl ?? (await resolveGiteaBaseUrl()); diff --git a/packages/gitea/src/index.ts b/packages/gitea/src/index.ts index b1c13e73..18ac6e09 100644 --- a/packages/gitea/src/index.ts +++ b/packages/gitea/src/index.ts @@ -1 +1,2 @@ export * from './api'; +export * from './oauth'; diff --git a/packages/gitea/src/oauth.ts b/packages/gitea/src/oauth.ts new file mode 100644 index 00000000..b6739b06 --- /dev/null +++ b/packages/gitea/src/oauth.ts @@ -0,0 +1,232 @@ +import { randomBytes } from 'node:crypto'; + +import { db, deploymentSecrets, eq } from '@roomote/db/server'; +import { decryptSecrets, encryptJSON } from '@roomote/db/encryption'; + +const SECRET_NAME = 'gitea_deployment_oauth_connection'; +const DEFAULT_SCOPES = [ + 'read:user', + 'read:repository', + 'write:repository', + 'write:issue', + 'read:organization', +] as const; + +export type GiteaOAuthConnectionStatus = 'active' | 'reauthorization_required'; + +export type GiteaOAuthConnection = { + baseUrl: string; + clientId: string; + clientSecret: string; + accountId: string; + username: string; + accessToken: string; + refreshToken: string; + expiresAt: string; + scopes: string[]; + status: GiteaOAuthConnectionStatus; +}; + +type GiteaOAuthTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + scope?: string; +}; + +let refreshPromise: Promise | null = null; +let cachedAccessToken: string | null = null; + +function tokenEndpoint(baseUrl: string): string { + return new URL( + 'login/oauth/access_token', + `${baseUrl.replace(/\/$/, '')}/`, + ).toString(); +} + +export function getGiteaOAuthScopes(): readonly string[] { + return DEFAULT_SCOPES; +} + +export function buildGiteaOAuthRedirectUri(appUrl: string): string { + return new URL( + '/api/source-control/gitea/oauth/callback', + `${appUrl.replace(/\/$/, '')}/`, + ).toString(); +} + +export function createGiteaOAuthAuthorizationUrl(input: { + baseUrl: string; + clientId: string; + redirectUri: string; + state?: string; + scopes?: readonly string[]; +}): { url: string; state: string } { + const state = input.state ?? randomBytes(32).toString('hex'); + const url = new URL( + '/login/oauth/authorize', + `${input.baseUrl.replace(/\/$/, '')}/`, + ); + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', input.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', state); + url.searchParams.set('scope', (input.scopes ?? DEFAULT_SCOPES).join(' ')); + return { url: url.toString(), state }; +} + +async function readConnection(): Promise { + const row = await db.query.deploymentSecrets?.findFirst?.({ + where: eq(deploymentSecrets.name, SECRET_NAME), + }); + return row ? await decryptSecrets(row.value) : null; +} + +async function writeConnection( + connection: GiteaOAuthConnection, +): Promise { + const value = encryptJSON(connection); + await db + .insert(deploymentSecrets) + .values({ name: SECRET_NAME, value }) + .onConflictDoUpdate({ + target: deploymentSecrets.name, + set: { value, updatedAt: new Date() }, + }); +} + +export async function getGiteaOAuthConnection(): Promise { + return readConnection(); +} + +export async function exchangeGiteaOAuthCode(input: { + baseUrl: string; + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; + fetchImpl?: typeof fetch; +}): Promise { + const response = await (input.fetchImpl ?? fetch)( + tokenEndpoint(input.baseUrl), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + grant_type: 'authorization_code', + redirect_uri: input.redirectUri, + }), + }, + ); + if (!response.ok) { + throw new Error( + `Gitea OAuth token exchange failed: ${response.status} ${response.statusText}`, + ); + } + const token = (await response.json()) as GiteaOAuthTokenResponse; + if (!token.access_token || !token.refresh_token) { + throw new Error('Gitea OAuth did not return an access and refresh token.'); + } + + const connection: GiteaOAuthConnection = { + baseUrl: input.baseUrl, + clientId: input.clientId, + clientSecret: input.clientSecret, + accountId: '', + username: '', + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: new Date( + Date.now() + (token.expires_in ?? 3600) * 1000, + ).toISOString(), + scopes: token.scope?.split(/\s+/).filter(Boolean) ?? [...DEFAULT_SCOPES], + status: 'active', + }; + const userResponse = await (input.fetchImpl ?? fetch)( + new URL('api/v1/user', `${input.baseUrl.replace(/\/$/, '')}/`), + { headers: { Authorization: `Bearer ${connection.accessToken}` } }, + ); + if (userResponse.ok) { + const user = (await userResponse.json()) as { id?: number; login?: string }; + connection.accountId = user.id === undefined ? '' : String(user.id); + connection.username = user.login ?? ''; + } + await writeConnection(connection); + cachedAccessToken = connection.accessToken; + return connection; +} + +export async function resolveGiteaOAuthAccessToken(options?: { + fetchImpl?: typeof fetch; + forceRefresh?: boolean; +}): Promise { + const connection = await readConnection(); + if (!connection || connection.status !== 'active') return null; + if ( + !options?.forceRefresh && + Date.parse(connection.expiresAt) > Date.now() + 60_000 + ) { + cachedAccessToken = connection.accessToken; + return connection.accessToken; + } + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + const response = await (options?.fetchImpl ?? fetch)( + tokenEndpoint(connection.baseUrl), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: connection.clientId, + client_secret: connection.clientSecret, + refresh_token: connection.refreshToken, + grant_type: 'refresh_token', + }), + }, + ); + if (!response.ok) { + await writeConnection({ + ...connection, + status: 'reauthorization_required', + }); + throw new Error( + 'Gitea OAuth authorization has expired and must be renewed.', + ); + } + const token = (await response.json()) as GiteaOAuthTokenResponse; + if (!token.access_token) + throw new Error('Gitea OAuth refresh did not return an access token.'); + const next = { + ...connection, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? connection.refreshToken, + expiresAt: new Date( + Date.now() + (token.expires_in ?? 3600) * 1000, + ).toISOString(), + scopes: token.scope?.split(/\s+/).filter(Boolean) ?? connection.scopes, + status: 'active' as const, + }; + await writeConnection(next); + cachedAccessToken = next.accessToken; + return next.accessToken; + })(); + try { + return await refreshPromise; + } finally { + refreshPromise = null; + } +} + +export function isGiteaOAuthAccessToken(token: string): boolean { + return token === cachedAccessToken; +} diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 30e84add..2082fec0 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -7,10 +7,12 @@ const { mockEnvironmentVariablesFindMany, mockRepositoriesFindMany, mockEnvironmentsFindFirst, + mockGitLabOAuthAccessToken, } = vi.hoisted(() => ({ mockEnvironmentVariablesFindMany: vi.fn(), mockRepositoriesFindMany: vi.fn(), mockEnvironmentsFindFirst: vi.fn(), + mockGitLabOAuthAccessToken: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -60,6 +62,11 @@ vi.mock('@roomote/db/encryption', () => ({ decryptSecrets: vi.fn(async (value: unknown) => value), })); +vi.mock('../oauth', () => ({ + isGitLabOAuthAccessToken: () => false, + resolveGitLabOAuthAccessToken: () => mockGitLabOAuthAccessToken(), +})); + import { buildGitLabApiBaseUrl, buildGitLabRepositoryValues, @@ -74,7 +81,6 @@ import { type GitLabProject, listGitLabProjects, normalizeGitLabBaseUrl, - validateGitLabToken, } from '../api'; function makeTaskRun(payload: TaskRun['payload']): TaskRun { @@ -210,7 +216,7 @@ describe('listGitLabProjects', () => { it('requires a token', async () => { await expect(listGitLabProjects({ token: '' })).rejects.toThrow( - 'GITLAB_TOKEN is required to sync GitLab repositories.', + 'GitLab OAuth authorization is required to sync repositories.', ); }); @@ -290,12 +296,11 @@ describe('buildGitLabRepositoryValues', () => { }); describe('createTaskRunScopedGitLabTokens', () => { - const originalGitLabToken = process.env.GITLAB_TOKEN; const originalGitLabBaseUrl = process.env.GITLAB_BASE_URL; beforeEach(() => { vi.clearAllMocks(); - process.env.GITLAB_TOKEN = 'glpat_deployment_token'; + mockGitLabOAuthAccessToken.mockResolvedValue('oauth_access_token'); delete process.env.GITLAB_BASE_URL; mockEnvironmentVariablesFindMany.mockResolvedValue([]); mockEnvironmentsFindFirst.mockResolvedValue(null); @@ -308,12 +313,6 @@ describe('createTaskRunScopedGitLabTokens', () => { }); afterEach(() => { - if (originalGitLabToken === undefined) { - delete process.env.GITLAB_TOKEN; - } else { - process.env.GITLAB_TOKEN = originalGitLabToken; - } - if (originalGitLabBaseUrl === undefined) { delete process.env.GITLAB_BASE_URL; } else { @@ -750,21 +749,14 @@ describe('createTaskRunScopedGitLabTokens', () => { }); describe('getGitLabDeploymentUser', () => { - const originalGitLabToken = process.env.GITLAB_TOKEN; - beforeEach(() => { vi.clearAllMocks(); clearGitLabDeploymentUserCache(); - process.env.GITLAB_TOKEN = 'glpat_deployment_token'; + mockGitLabOAuthAccessToken.mockResolvedValue('oauth_access_token'); }); afterEach(() => { clearGitLabDeploymentUserCache(); - if (originalGitLabToken === undefined) { - delete process.env.GITLAB_TOKEN; - } else { - process.env.GITLAB_TOKEN = originalGitLabToken; - } }); it('resolves the deployment identity via GET /user and caches it', async () => { @@ -842,7 +834,7 @@ describe('createGitLabMergeRequestNote', () => { fetchImpl: vi.fn(), }), ).rejects.toThrow( - 'GITLAB_TOKEN is required to create GitLab merge request notes.', + 'GitLab OAuth authorization is required to create merge request notes.', ); }); }); diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index b7fbdfbf..8791f8ba 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -134,8 +134,7 @@ export function normalizeGitLabBaseUrl(baseUrl: string): string { } export async function resolveGitLabToken(): Promise { - const oauthToken = await resolveGitLabOAuthAccessToken(); - return oauthToken ?? (await resolveDeploymentEnvVar('GITLAB_TOKEN')); + return resolveGitLabOAuthAccessToken(); } let cachedGitLabDeploymentUser: { @@ -199,7 +198,7 @@ export async function createGitLabMergeRequestNote({ if (!gitLabToken?.trim()) { throw new Error( - 'GITLAB_TOKEN is required to create GitLab merge request notes.', + 'GitLab OAuth authorization is required to create merge request notes.', ); } @@ -370,7 +369,9 @@ export async function listGitLabProjects({ const gitLabToken = token ?? (await resolveGitLabToken()); if (!gitLabToken?.trim()) { - throw new Error('GITLAB_TOKEN is required to sync GitLab repositories.'); + throw new Error( + 'GitLab OAuth authorization is required to sync repositories.', + ); } const resolvedApiBaseUrl = @@ -613,7 +614,9 @@ export async function ensureGitLabWebhooksForProjects({ const gitLabToken = token ?? (await resolveGitLabToken()); if (!gitLabToken?.trim()) { - throw new Error('GITLAB_TOKEN is required to configure GitLab webhooks.'); + throw new Error( + 'GitLab OAuth authorization is required to configure webhooks.', + ); } const results: GitLabWebhookEnsureResult[] = []; @@ -686,7 +689,9 @@ export async function removeGitLabWebhooksForProjects({ const gitLabToken = token ?? (await resolveGitLabToken()); if (!gitLabToken?.trim()) { - throw new Error('GITLAB_TOKEN is required to configure GitLab webhooks.'); + throw new Error( + 'GitLab OAuth authorization is required to configure webhooks.', + ); } const results: GitLabWebhookRemoveResult[] = []; @@ -1146,7 +1151,9 @@ export async function createTaskRunScopedGitLabTokens( const deploymentToken = await resolveGitLabToken(); if (!deploymentToken?.trim()) { - throw new Error('GITLAB_TOKEN is required for GitLab source control jobs.'); + throw new Error( + 'GitLab OAuth authorization is required for source-control jobs.', + ); } const baseUrl = options?.baseUrl ?? (await resolveGitLabBaseUrl()); diff --git a/packages/types/src/setup-source-control-config.test.ts b/packages/types/src/setup-source-control-config.test.ts index 717389e3..541650c0 100644 --- a/packages/types/src/setup-source-control-config.test.ts +++ b/packages/types/src/setup-source-control-config.test.ts @@ -5,6 +5,37 @@ import { } from './setup-source-control-config'; describe('buildSetupSourceControlStatus', () => { + it('does not treat GitLab as configured when no credentials are present', () => { + const status = buildSetupSourceControlStatus({}); + const gitlab = status.providers.find((p) => p.provider === 'gitlab'); + + expect(gitlab).toMatchObject({ + runtimeConfigSatisfied: false, + savedConfigSatisfied: false, + configSatisfied: false, + }); + expect(status.runtimeConfiguredProvider).toBeNull(); + expect(status.lockReason).toBeNull(); + }); + + it('accepts GitLab OAuth credentials', () => { + const status = buildSetupSourceControlStatus({ + runtimeEnv: { + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + }, + }); + + expect(status.runtimeConfiguredProvider).toBe('gitlab'); + expect(status.lockReason).toBe('runtime_env'); + expect(status.providers.find((p) => p.provider === 'gitlab')).toMatchObject( + { + runtimeConfigSatisfied: true, + configSatisfied: true, + }, + ); + }); + it('returns plain-text savedValue for non-secret fields only', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { @@ -77,7 +108,8 @@ describe('buildSetupSourceControlStatus', () => { it('does not mark setup satisfied when config is present but no repositories are connected', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { - GITLAB_TOKEN: 'gitlab-token', + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', }, }); @@ -95,7 +127,7 @@ describe('buildSetupSourceControlStatus', () => { it('prefers a connected provider when preselecting even without runtime config', () => { const status = buildSetupSourceControlStatus({ - persistedEnvVarNames: ['GITLAB_TOKEN'], + persistedEnvVarNames: ['GITLAB_CLIENT_ID', 'GITLAB_CLIENT_SECRET'], connectedProviders: ['gitlab'], repositoryCounts: { gitlab: 2 }, }); @@ -137,7 +169,10 @@ describe('buildSetupSourceControlStatus', () => { it('honors the selected provider override', () => { const status = buildSetupSourceControlStatus({ - runtimeEnv: { GITLAB_TOKEN: 'gitlab-token' }, + runtimeEnv: { + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + }, selectedProvider: 'ado', }); @@ -147,7 +182,7 @@ describe('buildSetupSourceControlStatus', () => { it('preselects saved config without locking or selecting the provider', () => { const status = buildSetupSourceControlStatus({ - persistedEnvVarNames: ['GITLAB_TOKEN'], + persistedEnvVarNames: ['GITLAB_CLIENT_ID', 'GITLAB_CLIENT_SECRET'], }); expect(status.preselectedProvider).toBe('gitlab'); @@ -166,7 +201,8 @@ describe('buildSetupSourceControlStatus', () => { R_GITHUB_CLIENT_SECRET: 'client-secret', R_GITHUB_WEBHOOK_SECRET: 'webhook-secret', R_GITHUB_APP_SLUG: 'roomote', - GITLAB_TOKEN: 'gitlab-token', + GITLAB_CLIENT_ID: 'gitlab-client-id', + GITLAB_CLIENT_SECRET: 'gitlab-client-secret', }, }); @@ -233,7 +269,8 @@ describe('buildSetupSourceControlStatus', () => { const gitlabStatus = buildSetupSourceControlStatus({ runtimeEnv: { - GITLAB_TOKEN: 'gitlab-token', + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', }, }); const gitlab = gitlabStatus.providers.find((p) => p.provider === 'gitlab'); @@ -316,18 +353,21 @@ describe('buildSetupSourceControlStatus', () => { it('treats runtime env as the highest-precedence satisfaction source', () => { const status = buildSetupSourceControlStatus({ - runtimeEnv: { GITLAB_TOKEN: 'runtime-token' }, - persistedEnvVarNames: ['GITLAB_TOKEN'], + runtimeEnv: { + GITLAB_CLIENT_ID: 'runtime-client-id', + GITLAB_CLIENT_SECRET: 'runtime-client-secret', + }, + persistedEnvVarNames: ['GITLAB_CLIENT_ID', 'GITLAB_CLIENT_SECRET'], }); const gitlab = status.providers.find((p) => p.provider === 'gitlab'); - const tokenField = gitlab?.fields.find( - (f) => f.envVarName === 'GITLAB_TOKEN', + const clientIdField = gitlab?.fields.find( + (f) => f.envVarName === 'GITLAB_CLIENT_ID', ); - expect(tokenField).toMatchObject({ + expect(clientIdField).toMatchObject({ runtimeSatisfied: true, savedSatisfied: true, - satisfiedByEnvVarName: 'GITLAB_TOKEN', + satisfiedByEnvVarName: 'GITLAB_CLIENT_ID', }); }); @@ -365,7 +405,10 @@ describe('buildSetupSourceControlStatus', () => { it('reports setup satisfied by runtime env when the connected provider is runtime-configured', () => { const status = buildSetupSourceControlStatus({ - runtimeEnv: { GITLAB_TOKEN: 'runtime-token' }, + runtimeEnv: { + GITLAB_CLIENT_ID: 'runtime-client-id', + GITLAB_CLIENT_SECRET: 'runtime-client-secret', + }, persistedEnvVarNames: [], connectedProviders: ['gitlab'], repositoryCounts: { gitlab: 1 }, @@ -377,7 +420,7 @@ describe('buildSetupSourceControlStatus', () => { it('does not report setup satisfied by runtime env when the connected provider is only saved-configured', () => { const status = buildSetupSourceControlStatus({ - persistedEnvVarNames: ['GITLAB_TOKEN'], + persistedEnvVarNames: ['GITLAB_CLIENT_ID', 'GITLAB_CLIENT_SECRET'], connectedProviders: ['gitlab'], repositoryCounts: { gitlab: 1 }, }); @@ -431,8 +474,8 @@ describe('getSetupSourceControlVisibleFields', () => { }); it.each([ - ['gitlab', ['GITLAB_TOKEN', 'GITLAB_BASE_URL']], - ['gitea', ['GITEA_BASE_URL', 'GITEA_TOKEN']], + ['gitlab', ['GITLAB_BASE_URL', 'GITLAB_CLIENT_ID', 'GITLAB_CLIENT_SECRET']], + ['gitea', ['GITEA_BASE_URL', 'GITEA_CLIENT_ID', 'GITEA_CLIENT_SECRET']], ['bitbucket', ['BITBUCKET_TOKEN', 'BITBUCKET_USERNAME']], ] as const)( 'keeps %s setup focused on required connection values', diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index 03c933bf..415f5c61 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -240,14 +240,6 @@ function buildProviderFields( ]; case 'gitlab': return [ - { - envVarName: 'GITLAB_TOKEN', - acceptedEnvVarNames: ['GITLAB_TOKEN'], - label: 'GitLab Automation Token', - secret: true, - required: false, - advanced: true, - }, { envVarName: 'GITLAB_BASE_URL', acceptedEnvVarNames: ['GITLAB_BASE_URL'], @@ -258,14 +250,12 @@ function buildProviderFields( envVarName: 'GITLAB_CLIENT_ID', acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], label: 'GitLab OAuth Client ID', - required: false, }, { envVarName: 'GITLAB_CLIENT_SECRET', acceptedEnvVarNames: ['GITLAB_CLIENT_SECRET'], label: 'GitLab OAuth Client Secret', secret: true, - required: false, }, { envVarName: 'GITLAB_WEBHOOK_SIGNING_TOKEN', @@ -291,33 +281,18 @@ function buildProviderFields( acceptedEnvVarNames: ['GITEA_BASE_URL'], label: 'Gitea Base URL', }, - { - envVarName: 'GITEA_TOKEN', - acceptedEnvVarNames: ['GITEA_TOKEN'], - label: 'Gitea Access Token', - secret: true, - }, - { - envVarName: 'GITEA_USERNAME', - acceptedEnvVarNames: ['GITEA_USERNAME'], - label: 'Gitea Username', - required: false, - setupHidden: true, - }, { envVarName: 'GITEA_CLIENT_ID', acceptedEnvVarNames: ['GITEA_CLIENT_ID'], label: 'Gitea OAuth Client ID', - required: false, - setupHidden: true, + required: true, }, { envVarName: 'GITEA_CLIENT_SECRET', acceptedEnvVarNames: ['GITEA_CLIENT_SECRET'], label: 'Gitea OAuth Client Secret', secret: true, - required: false, - setupHidden: true, + required: true, }, { envVarName: 'GITEA_WEBHOOK_SECRET', @@ -492,15 +467,18 @@ export function buildSetupSourceControlStatus(input: { }); const requiredFields = fields.filter(isRequiredField); - const runtimeConfigSatisfied = requiredFields.every( + const requiredFieldsSatisfied = requiredFields.every( (field) => field.runtimeSatisfied, ); - const savedConfigSatisfied = requiredFields.every( + const requiredSavedFieldsSatisfied = requiredFields.every( (field) => field.savedSatisfied, ); - const standardConfigSatisfied = requiredFields.every( + const standardRequiredFieldsSatisfied = requiredFields.every( (field) => field.runtimeSatisfied || field.savedSatisfied, ); + const runtimeConfigSatisfied = requiredFieldsSatisfied; + const savedConfigSatisfied = requiredSavedFieldsSatisfied; + const standardConfigSatisfied = standardRequiredFieldsSatisfied; const adoCredentialSatisfied = descriptor.provider !== 'ado' || isAdoCredentialConfigured(fields, 'effective'); From 095cb074853a18feeb22bf1f679528bcc865b839 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 14:41:33 +0100 Subject: [PATCH 11/31] test(gitlab): update OAuth API expectations --- packages/gitlab/src/__tests__/api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 2082fec0..10d4fd1f 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -777,14 +777,14 @@ describe('getGitLabDeploymentUser', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - 'PRIVATE-TOKEN': 'glpat_deployment_token', + 'PRIVATE-TOKEN': 'oauth_access_token', }), }), ); }); it('returns null when no deployment token is configured', async () => { - delete process.env.GITLAB_TOKEN; + mockGitLabOAuthAccessToken.mockResolvedValue(null); const fetchMock = vi.fn(); await expect( From 50e330cb579a6afc205ea07fc9522102a5adf8b4 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 14:43:23 +0100 Subject: [PATCH 12/31] feat(setup): show Gitea OAuth redirect instructions --- .../setup/GiteaSourceControlConfig.tsx | 25 ++++++++++++++++ .../setup/StepSourceControlConfig.tsx | 29 +++++++++---------- .../setup/sourceControlSetupCopy.ts | 2 +- 3 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx diff --git a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx new file mode 100644 index 00000000..5c5ea971 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx @@ -0,0 +1,25 @@ +import { InstructionUrl } from './ProviderSetupInstructions'; + +export function GiteaSourceControlInstructions({ + publicOrigin, +}: { + publicOrigin: string; +}) { + return ( +

+

+ Configure the Gitea application. +

+

+ In Gitea 1.23 or newer, create an OAuth application for the dedicated + service account. Set the callback URL below, then request read:user, + read:repository, write:repository, write:issue, and read:organization + scopes. +

+ +
+ ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index cf3276b6..a14b4124 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -28,6 +28,7 @@ import { DEFAULT_ADO_AUTH_MODE, } from './AdoSourceControlConfig'; import { GitHubSourceControlConfig } from './GitHubSourceControlConfig'; +import { GiteaSourceControlInstructions } from './GiteaSourceControlConfig'; import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; @@ -390,16 +391,22 @@ export function StepSourceControlConfig({ )} - {isAdo ? ( + {isAdo || selectedProvider?.provider === 'gitea' ? ( - + {isAdo ? ( + + ) : ( + + )} ) : null} - +

Enter the values below for your {provider ?? 'source control'}{' '} integration. @@ -430,16 +437,6 @@ export function StepSourceControlConfig({ the application credentials.

)} - {selectedProvider?.provider === 'gitea' && ( -

- OAuth redirect URI:{' '} - - {publicOrigin}/api/source-control/gitea/oauth/callback - - . Create the application in Gitea 1.23 or newer, then authorize it - with the dedicated service account. -

- )} {(isAdo ? baseFields : visibleFields).map((field) => ( Date: Tue, 14 Jul 2026 14:51:05 +0100 Subject: [PATCH 13/31] Remove GitLab PAT deployment fallback --- .../commands/source-control/index.test.ts | 1 - packages/gitlab/src/__tests__/api.test.ts | 56 +---------------- packages/gitlab/src/api.ts | 60 +------------------ 3 files changed, 5 insertions(+), 112 deletions(-) diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index ff26c385..27a8b1de 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -64,7 +64,6 @@ vi.mock('@roomote/gitlab', () => ({ removeGitLabWebhooksForProjects: mockRemoveGitLabWebhooksForProjects, resolveGitLabBaseUrl: vi.fn().mockResolvedValue('https://gitlab.com'), syncGitLabRepositories: vi.fn(), - validateGitLabToken: vi.fn().mockResolvedValue({ status: 'valid' }), })); vi.mock('@roomote/db/server', () => ({ diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 10d4fd1f..e464ba61 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -366,7 +366,7 @@ describe('createTaskRunScopedGitLabTokens', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - 'PRIVATE-TOKEN': 'glpat_deployment_token', + 'PRIVATE-TOKEN': 'oauth_access_token', 'Content-Type': 'application/json', }), body: expect.stringContaining('"write_repository"'), @@ -647,7 +647,7 @@ describe('createTaskRunScopedGitLabTokens', () => { host: 'gitlab.com', repositoryFullName: 'group/project', username: 'oauth2', - token: 'glpat_deployment_token', + token: 'oauth_access_token', }, ], artifactsPatch: { @@ -783,7 +783,7 @@ describe('getGitLabDeploymentUser', () => { ); }); - it('returns null when no deployment token is configured', async () => { + it('returns null when no GitLab OAuth connection is configured', async () => { mockGitLabOAuthAccessToken.mockResolvedValue(null); const fetchMock = vi.fn(); @@ -839,56 +839,6 @@ describe('createGitLabMergeRequestNote', () => { }); }); -describe('validateGitLabToken', () => { - it('returns the authenticated username for a working token', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ id: 7, username: 'roomote-bot' }), { - status: 200, - }), - ); - - await expect( - validateGitLabToken({ token: 'glpat_test', fetchImpl: fetchMock }), - ).resolves.toEqual({ status: 'valid', username: 'roomote-bot' }); - - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/user', - expect.objectContaining({ - headers: expect.objectContaining({ 'PRIVATE-TOKEN': 'glpat_test' }), - }), - ); - }); - - it('reports definitive auth failures as invalid', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ message: '401 Unauthorized' }), { - status: 401, - statusText: 'Unauthorized', - }), - ); - - const result = await validateGitLabToken({ - token: 'glpat_bad', - fetchImpl: fetchMock, - }); - - expect(result.status).toBe('invalid'); - }); - - it('reports transient failures as unknown instead of invalid', async () => { - const fetchMock = vi - .fn() - .mockRejectedValue(new Error('network unreachable')); - - const result = await validateGitLabToken({ - token: 'glpat_test', - fetchImpl: fetchMock, - }); - - expect(result.status).toBe('unknown'); - }); -}); - describe('ensureGitLabWebhooksForProjects', () => { it('creates missing webhooks and refreshes existing ones by URL', async () => { const fetchMock = vi diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 8791f8ba..1c1c92ff 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -143,7 +143,8 @@ let cachedGitLabDeploymentUser: { } | null = null; /** - * Resolves the GitLab identity behind the deployment token via `GET /user`. + * Resolves the GitLab identity behind the deployment OAuth connection via + * `GET /user`. * The result is cached per token value so webhook handlers can call this on * every delivery without re-hitting the GitLab API. */ @@ -421,63 +422,6 @@ export async function listGitLabProjects({ return projects; } -export type GitLabTokenValidationResult = - | { status: 'valid'; username: string } - | { status: 'invalid'; error: string } - | { status: 'unknown'; error: string }; - -const GITLAB_TOKEN_VALIDATION_TIMEOUT_MS = 10_000; - -/** - * Verifies that a GitLab token can authenticate against the GitLab API. - * Returns `invalid` only for definitive auth failures so transient network - * or GitLab availability issues do not block saving configuration. The - * request is bounded by a timeout so callers are never held open by a slow - * GitLab response. - */ -export async function validateGitLabToken({ - token, - apiBaseUrl, - fetchImpl = fetch, - timeoutMs = GITLAB_TOKEN_VALIDATION_TIMEOUT_MS, -}: { - token: string; - apiBaseUrl?: string; - fetchImpl?: typeof fetch; - timeoutMs?: number; -}): Promise { - const timedFetch: typeof fetch = (input, init) => - fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }); - - try { - const { data } = await requestGitLabJson({ - apiBaseUrl, - fetchImpl: timedFetch, - path: '/user', - params: {}, - token, - schema: gitLabUserSchema, - }); - - return { status: 'valid', username: data.username }; - } catch (error) { - if (error instanceof GitLabApiError && [401, 403].includes(error.status)) { - return { - status: 'invalid', - error: - 'GitLab rejected the token. Confirm the token is active and has the api scope.', - }; - } - - return { - status: 'unknown', - error: `Could not verify the GitLab token: ${ - error instanceof Error ? error.message : String(error) - }`, - }; - } -} - const GITLAB_WEBHOOK_EVENT_FLAGS = { merge_requests_events: true, note_events: true, From 2f26b093cf9280c5239c0ae2fd09650ab377b27e Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 14:52:17 +0100 Subject: [PATCH 14/31] feat(setup): authorize Gitea after saving credentials --- .../setup/StepSourceControlConfig.client.test.tsx | 4 ++-- .../src/app/(onboarding)/setup/StepSourceControlConfig.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index c56b861b..4f71079f 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -269,8 +269,8 @@ describe('StepSourceControlConfig', () => { expect(screen.getByText('Gitea Base URL')).toBeInTheDocument(); expect(screen.getByText(/Gitea OAuth Client ID/)).toBeInTheDocument(); expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); - expect(screen.getByText(/Gitea 1\.23 or newer/)).toBeInTheDocument(); - expect(screen.getByText(/read:user, read:repository/)).toBeInTheDocument(); + expect(screen.getByText(/Gitea 1\.23\+/)).toBeInTheDocument(); + expect(screen.getByText('Web redirect URI')).toBeInTheDocument(); expect(screen.queryByText('Gitea Access Token')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index a14b4124..a971f4eb 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -311,6 +311,10 @@ export function StepSourceControlConfig({ : {}), }, }); + + if (selectedProvider.provider === 'gitea') { + window.location.assign('/api/source-control/gitea/oauth/authorize'); + } }; const provider = selectedProvider?.label; From e17104d2c678172d81dbcf678e15cb37e9d25a12 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 14:54:40 +0100 Subject: [PATCH 15/31] docs(setup): clarify Gitea OAuth instructions --- .../setup/GiteaSourceControlConfig.tsx | 7 +------ .../setup/StepSourceControlConnect.tsx | 15 ++------------- .../(onboarding)/setup/sourceControlSetupCopy.ts | 2 +- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx index 5c5ea971..db677aa7 100644 --- a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx @@ -10,12 +10,7 @@ export function GiteaSourceControlInstructions({

Configure the Gitea application.

-

- In Gitea 1.23 or newer, create an OAuth application for the dedicated - service account. Set the callback URL below, then request read:user, - read:repository, write:repository, write:issue, and read:organization - scopes. -

+

Enter copy the redirect URL below:

) : null} - {giteaOAuthConfigured ? ( -

- Authorize the Gitea application with the dedicated service account - before syncing repositories. - - Authorize Gitea - -

- ) : null} + {syncedWithZeroRepos ? (

No repositories were found. Check your token permissions and base diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index 93e1b5d5..c5694888 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -26,7 +26,7 @@ const SOURCE_CONTROL_SETUP_COPY: Record< gitea: { setupLabel: 'Gitea OAuth application', creationHint: - 'Create an OAuth application in Gitea 1.23+ for the dedicated service account. The next step shows the callback URL and required scopes.', + 'In Gitea 1.23+, go to your org → Settings → Application → New OAuth2 app.', }, bitbucket: { creationHref: 'https://id.atlassian.com/manage-profile/security/api-tokens', From f5ab6c0536b377827dd6ff6d47779db3acba0ecc Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 15:56:44 +0100 Subject: [PATCH 16/31] gitlab oauth fixes --- .../setup/GitLabSourceControlConfig.tsx | 20 +++++++ .../StepSourceControlConfig.client.test.tsx | 4 +- .../setup/StepSourceControlConfig.tsx | 54 +++++++++++-------- .../setup/StepSourceControlConnect.tsx | 19 ++----- .../setup/sourceControlSetupCopy.ts | 3 +- packages/gitlab/src/oauth.ts | 5 +- .../types/src/setup-source-control-config.ts | 4 +- 7 files changed, 64 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx diff --git a/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx new file mode 100644 index 00000000..50fea71e --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx @@ -0,0 +1,20 @@ +import { InstructionUrl } from './ProviderSetupInstructions'; + +export function GitLabSourceControlInstructions({ + publicOrigin, +}: { + publicOrigin: string; +}) { + return ( +

+

+ Configure the GitLab OAuth application. +

+

Copy the redirect URL below into GitLab:

+ +
+ ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 4f71079f..ec549b4a 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -250,7 +250,9 @@ describe('StepSourceControlConfig', () => { expect( screen.getAllByText(/GitLab OAuth application/).length, ).toBeGreaterThan(0); - expect(screen.getByText(/OAuth application in GitLab/)).toBeInTheDocument(); + expect( + screen.getByText(/In GitLab, create an OAuth application/), + ).toBeInTheDocument(); expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Create GitHub App' }), diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index a971f4eb..9710048d 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -29,9 +29,11 @@ import { } from './AdoSourceControlConfig'; import { GitHubSourceControlConfig } from './GitHubSourceControlConfig'; import { GiteaSourceControlInstructions } from './GiteaSourceControlConfig'; +import { GitLabSourceControlInstructions } from './GitLabSourceControlConfig'; import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; +const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; type SourceControlField = SetupSourceControlStatus['providers'][number]['fields'][number]; @@ -53,6 +55,8 @@ function getNonSecretFieldInitialValues( const savedValue = field.savedValue?.trim(); if (savedValue) { next[field.envVarName] = savedValue; + } else if (field.envVarName === 'GITLAB_BASE_URL') { + next[field.envVarName] = DEFAULT_GITLAB_BASE_URL; } } @@ -312,8 +316,13 @@ export function StepSourceControlConfig({ }, }); - if (selectedProvider.provider === 'gitea') { - window.location.assign('/api/source-control/gitea/oauth/authorize'); + if ( + selectedProvider.provider === 'gitea' || + selectedProvider.provider === 'gitlab' + ) { + window.location.assign( + `/api/source-control/${selectedProvider.provider}/oauth/authorize`, + ); } }; @@ -326,16 +335,15 @@ export function StepSourceControlConfig({ typeof window === 'undefined' ? 'https://your-deployment-url' : window.location.origin; - const typedGitLabBaseUrl = - values['GITLAB_BASE_URL']?.trim().replace(/\/+$/, '') ?? ''; - const configuredGitLabBaseUrl = - sourceControlSetup.gitlabBaseUrl?.trim().replace(/\/+$/, '') ?? ''; - const effectiveGitLabBaseUrl = normalizeGitLabSetupUrl( - typedGitLabBaseUrl || configuredGitLabBaseUrl, - ); + const rawGitLabBaseUrl = + values['GITLAB_BASE_URL']?.trim() || + sourceControlSetup.gitlabBaseUrl?.trim() || + ''; const creationHref = selectedProvider?.provider === 'gitlab' - ? `${effectiveGitLabBaseUrl}/-/user_settings/applications` + ? rawGitLabBaseUrl + ? `${normalizeGitLabSetupUrl(rawGitLabBaseUrl)}/-/user_settings/applications` + : undefined : providerSetupCopy?.creationHref; if (selectedProvider?.provider === 'github' && !showManualGitHubValues) { @@ -395,21 +403,31 @@ export function StepSourceControlConfig({
)} - {isAdo || selectedProvider?.provider === 'gitea' ? ( + {isAdo || + selectedProvider?.provider === 'gitea' || + selectedProvider?.provider === 'gitlab' ? ( {isAdo ? ( - ) : ( + ) : selectedProvider?.provider === 'gitea' ? ( + ) : ( + )} ) : null}

Enter the values below for your {provider ?? 'source control'}{' '} @@ -431,16 +449,6 @@ export function StepSourceControlConfig({ Roomote should use.

)} - {selectedProvider?.provider === 'gitlab' && ( -

- OAuth redirect URI:{' '} - - {publicOrigin}/api/source-control/gitlab/oauth/callback - - . Authorize GitLab with the dedicated service account after saving - the application credentials. -

- )} {(isAdo ? baseFields : visibleFields).map((field) => (

{tokenBackedCopy}

- {gitlabOAuthConfigured ? ( -

- Authorize the GitLab application with the dedicated service - account before syncing repositories. - - Authorize GitLab - -

- ) : null} - {syncedWithZeroRepos ? (

No repositories were found. Check your token permissions and base diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index c5694888..a21cb263 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -18,10 +18,9 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { - creationHref: 'https://gitlab.com/-/user_settings/applications', setupLabel: 'GitLab OAuth application', creationHint: - 'Create an OAuth application in GitLab with api scope. Use the exact redirect URI shown by Roomote, then authorize it with the dedicated service account.', + 'In GitLab, click on your avatar → Edit Profile → Applications → Add new application, granting the API read/write scope.', }, gitea: { setupLabel: 'Gitea OAuth application', diff --git a/packages/gitlab/src/oauth.ts b/packages/gitlab/src/oauth.ts index 6221d16e..013e7711 100644 --- a/packages/gitlab/src/oauth.ts +++ b/packages/gitlab/src/oauth.ts @@ -4,7 +4,10 @@ import { db, deploymentSecrets, eq } from '@roomote/db/server'; import { decryptSecrets, encryptJSON } from '@roomote/db/encryption'; const SECRET_NAME = 'gitlab_deployment_oauth_connection'; -const DEFAULT_SCOPES = ['api', 'read_repository', 'write_repository'] as const; +// GitLab OAuth applications use `api` for repository read/write access. +// `read_repository` and `write_repository` are deploy/project token scopes, +// not valid OAuth scopes. +const DEFAULT_SCOPES = ['api'] as const; export type GitLabOAuthConnectionStatus = 'active' | 'reauthorization_required'; diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index 415f5c61..9ccc884c 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -249,12 +249,12 @@ function buildProviderFields( { envVarName: 'GITLAB_CLIENT_ID', acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], - label: 'GitLab OAuth Client ID', + label: 'OAuth Application ID', }, { envVarName: 'GITLAB_CLIENT_SECRET', acceptedEnvVarNames: ['GITLAB_CLIENT_SECRET'], - label: 'GitLab OAuth Client Secret', + label: 'OAuth App Secret', secret: true, }, { From f28dee8209eadb347f1cf8c3dcc7f2afadc36d1f Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 16:00:02 +0100 Subject: [PATCH 17/31] sync fix --- packages/gitlab/src/oauth.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/gitlab/src/oauth.ts b/packages/gitlab/src/oauth.ts index 013e7711..2f15081e 100644 --- a/packages/gitlab/src/oauth.ts +++ b/packages/gitlab/src/oauth.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { db, deploymentSecrets, eq } from '@roomote/db/server'; +import { db, deploymentSecrets, sql } from '@roomote/db/server'; import { decryptSecrets, encryptJSON } from '@roomote/db/encryption'; const SECRET_NAME = 'gitlab_deployment_oauth_connection'; @@ -70,14 +70,21 @@ export function createGitLabOAuthAuthorizationUrl(input: { } async function readConnection(): Promise { - const findFirst = db.query.deploymentSecrets?.findFirst; - // Older test harnesses and pre-migration deployments may not expose the - // deployment-secrets relation yet; PAT resolution remains available there. - if (!findFirst) return null; - const row = await findFirst({ - where: eq(deploymentSecrets.name, SECRET_NAME), - }); - return row ? await decryptSecrets(row.value) : null; + // Use raw SQL here because some deployed DB package versions expose the + // relation without the current column metadata. PAT resolution remains + // available when the deployment-secrets table is unavailable. + try { + const rows = await db.execute<{ value: string }>(sql` + SELECT value + FROM deployment_secrets + WHERE name = ${SECRET_NAME} + LIMIT 1 + `); + const row = rows[0]; + return row ? await decryptSecrets(row.value) : null; + } catch { + return null; + } } async function writeConnection( From e6f23df413ad879e2d337f0360ceeade3bead575 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 16:16:54 +0100 Subject: [PATCH 18/31] Automatically sync repositories during onboarding --- .../StepSourceControlConnect.client.test.tsx | 128 +++++++++++++++++- .../setup/StepSourceControlConnect.tsx | 40 +++++- .../gitea/oauth/callback/route.ts | 1 + .../gitlab/oauth/callback/route.ts | 1 + 4 files changed, 162 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.client.test.tsx index bb484968..575b81b6 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.client.test.tsx @@ -210,6 +210,7 @@ describe('StepSourceControlConnect', () => { value: { ...window.location, pathname: '/setup', + search: '', }, }); }); @@ -247,7 +248,7 @@ describe('StepSourceControlConnect', () => { fireEvent.click(screen.getByRole('button', { name: /Sync repositories/i })); - expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(1); + expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(2); }); it('describes Gitea webhook setup during token-backed onboarding', () => { @@ -263,12 +264,12 @@ describe('StepSourceControlConnect', () => { ); expect( - screen.getByText(/pull request webhooks on the synced repositories/i), + screen.getByText(/Sync your Gitea repositories/i), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /Sync repositories/i })); - expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(1); + expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(2); expect(syncRepositoriesOptionsRef.current?.provider).toBe('gitea'); }); @@ -285,14 +286,12 @@ describe('StepSourceControlConnect', () => { ); expect( - screen.getByText( - /pull request service hooks on the synced repositories/i, - ), + screen.getByText(/Sync your Azure DevOps repositories/i), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /Sync repositories/i })); - expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(1); + expect(syncRepositoriesMutateMock).toHaveBeenCalledTimes(2); expect(syncRepositoriesOptionsRef.current?.provider).toBe('ado'); }); @@ -339,6 +338,121 @@ describe('StepSourceControlConnect', () => { expect(syncRepositoriesMutateMock).not.toHaveBeenCalled(); }); + it('does not auto-sync an OAuth-configured GitLab provider before OAuth completes', () => { + render( + , + ); + + expect(syncRepositoriesMutateMock).not.toHaveBeenCalled(); + }); + + it('auto-syncs OAuth-configured GitLab once the callback marker is present', () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, pathname: '/setup', search: '?sync=1' }, + }); + + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(syncRepositoriesMutateMock).toHaveBeenCalledOnce(); + }); + it('reports Gitea webhook setup failures as repositories', async () => { const onContinue = vi.fn(); diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx index 6564529f..a29ebf66 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -198,6 +198,21 @@ export function StepSourceControlConnect({ (field.runtimeSatisfied || field.savedSatisfied), ); + const oauthAutoSyncMarkerPresent = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('sync') === '1'; + const shouldAutoSync = + isSourceControlTokenBackedProvider(provider) && + providerStatus?.configSatisfied === true && + !alreadyConnected && + !needsAdoMicrosoftConnection && + (provider !== 'gitlab' || + !gitlabOAuthConfigured || + oauthAutoSyncMarkerPresent) && + (provider !== 'gitea' || + !giteaOAuthConfigured || + oauthAutoSyncMarkerPresent); + const handleSyncRepositories = async () => { if ( provider === 'ado' && @@ -216,6 +231,29 @@ export function StepSourceControlConnect({ syncRepositories.mutate(); }; + const autoSyncAttempted = useRef(false); + useEffect(() => { + if (!shouldAutoSync || autoSyncAttempted.current) { + return; + } + + autoSyncAttempted.current = true; + void handleSyncRepositories(); + + if (oauthAutoSyncMarkerPresent) { + const params = new URLSearchParams(window.location.search); + params.delete('sync'); + const query = params.toString(); + window.history.replaceState( + {}, + '', + query + ? `${window.location.pathname}?${query}` + : window.location.pathname, + ); + } + }, [handleSyncRepositories, oauthAutoSyncMarkerPresent, shouldAutoSync]); + return (

diff --git a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts index 4bb06c7b..0741ba02 100644 --- a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts @@ -48,6 +48,7 @@ export async function GET(request: NextRequest) { redirectUri: buildGiteaOAuthRedirectUri(webEnv.R_APP_URL), }); redirect.searchParams.set('gitea', 'connected'); + redirect.searchParams.set('sync', '1'); } catch (error) { console.error('[Gitea OAuth] callback failed', error); redirect.searchParams.set('gitea', 'error'); diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts index 5402b182..1f096e30 100644 --- a/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts @@ -49,6 +49,7 @@ export async function GET(request: NextRequest) { redirectUri: buildGitLabOAuthRedirectUri(webEnv.R_APP_URL), }); redirect.searchParams.set('gitlab', 'connected'); + redirect.searchParams.set('sync', '1'); } catch (error) { console.error('[GitLab OAuth] callback failed', error); redirect.searchParams.set('gitlab', 'error'); From 52bcf37729b38d0dc1ba90117f5f4436248be480 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 16:26:16 +0100 Subject: [PATCH 19/31] invoke --- apps/web/src/app/(onboarding)/invokeMethods.tsx | 10 +++++++--- .../src/app/(onboarding)/onboarding/StepInvoke.tsx | 5 ----- .../app/(onboarding)/setup/StepInvoke.client.test.tsx | 11 +++++++++++ apps/web/src/app/(onboarding)/setup/StepInvoke.tsx | 6 ------ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/(onboarding)/invokeMethods.tsx b/apps/web/src/app/(onboarding)/invokeMethods.tsx index 14ea7ce0..24683a7c 100644 --- a/apps/web/src/app/(onboarding)/invokeMethods.tsx +++ b/apps/web/src/app/(onboarding)/invokeMethods.tsx @@ -52,7 +52,6 @@ const sourceControlProviderCopy: Record< { icon: string; description: string; - example?: string; } > = { github: { @@ -61,7 +60,8 @@ const sourceControlProviderCopy: Record< }, gitlab: { icon: 'gitlab', - description: `Start work from connected GitLab merge requests and repositories.`, + description: `Mention @roomote in a comment on any merge request.`, + example: '@roomote address the merge request feedback above', }, gitea: { icon: 'gitea', @@ -150,7 +150,11 @@ export function buildInvokeMethods({ icon: createBrandIcon(copy.icon, title), title, description, - ...(identity?.examplePrompt ? { example: identity.examplePrompt } : {}), + ...(identity?.examplePrompt + ? { example: identity.examplePrompt } + : copy.example + ? { example: copy.example } + : {}), }; }), ...(includeLinear diff --git a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx index 94d7cc98..6e1f62aa 100644 --- a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx @@ -86,11 +86,6 @@ export function StepInvoke({

{method.description}

- {'example' in method && ( -

- {method.example} -

- )}
))} diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index b6709b19..6aa27f35 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -343,6 +343,17 @@ describe('Setup StepInvoke', () => { ).toBeInTheDocument(); }); + it('clarifies that GitLab mentions work on any merge request', () => { + render(); + + expect( + screen.getByText('Mention @roomote in a comment on any merge request.'), + ).toBeInTheDocument(); + expect( + screen.getByText('@roomote address the merge request feedback above'), + ).toBeInTheDocument(); + }); + it('shows configured providers with automations before the web UI', () => { render( {method.title}: {method.description}

- {'example' in method && ( -

- - {method.example} -

- )} ))} From baa138f4c7419c71c66347689c36b3885d7341ae Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:04:54 +0100 Subject: [PATCH 20/31] fix GitLab webhook setup after OAuth sync --- .../src/app/(onboarding)/invokeMethods.tsx | 2 +- .../commands/source-control/index.test.ts | 53 ++++++++++++++++++- .../src/trpc/commands/source-control/index.ts | 21 +++++--- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(onboarding)/invokeMethods.tsx b/apps/web/src/app/(onboarding)/invokeMethods.tsx index 24683a7c..dcd8b340 100644 --- a/apps/web/src/app/(onboarding)/invokeMethods.tsx +++ b/apps/web/src/app/(onboarding)/invokeMethods.tsx @@ -27,7 +27,6 @@ const communicationProviderCopy: Record< icon: string; title: string; description: string; - example?: string; } > = { slack: { @@ -52,6 +51,7 @@ const sourceControlProviderCopy: Record< { icon: string; description: string; + example?: string; } > = { github: { diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 27a8b1de..cd0397e6 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -3,12 +3,14 @@ import type { UserAuthSuccess } from '@/types'; const { mockEnsureAdoServiceHooksForRepositories, mockEnsureGiteaWebhooksForRepositories, + mockEnsureGitLabWebhooksForProjects, mockRemoveAdoServiceHooksForRepositories, mockRemoveGiteaWebhooksForRepositories, mockRemoveGitLabWebhooksForProjects, mockEnvironmentMappingRows, mockResolveDeploymentEnvVar, mockSyncAdoRepositories, + mockSyncGitLabRepositories, mockSyncGiteaRepositories, mockUpsertDeploymentEnvironmentVariables, mockResolveAdoOrganization, @@ -18,12 +20,14 @@ const { } = vi.hoisted(() => ({ mockEnsureAdoServiceHooksForRepositories: vi.fn(), mockEnsureGiteaWebhooksForRepositories: vi.fn(), + mockEnsureGitLabWebhooksForProjects: vi.fn(), mockRemoveAdoServiceHooksForRepositories: vi.fn(), mockRemoveGiteaWebhooksForRepositories: vi.fn(), mockRemoveGitLabWebhooksForProjects: vi.fn(), mockEnvironmentMappingRows: { rows: [] as { repositoryId: string }[] }, mockResolveDeploymentEnvVar: vi.fn(), mockSyncAdoRepositories: vi.fn(), + mockSyncGitLabRepositories: vi.fn(), mockSyncGiteaRepositories: vi.fn(), mockUpsertDeploymentEnvironmentVariables: vi.fn(), mockResolveAdoOrganization: vi.fn(), @@ -58,12 +62,12 @@ vi.mock('@roomote/gitea', () => ({ vi.mock('@roomote/gitlab', () => ({ buildGitLabApiBaseUrl: (value: string) => `${value.replace(/\/+$/, '')}/api/v4`, - ensureGitLabWebhooksForProjects: vi.fn(), + ensureGitLabWebhooksForProjects: mockEnsureGitLabWebhooksForProjects, normalizeGitLabBaseUrl: (value: string) => value.startsWith('http') ? value : `https://${value}`, removeGitLabWebhooksForProjects: mockRemoveGitLabWebhooksForProjects, resolveGitLabBaseUrl: vi.fn().mockResolvedValue('https://gitlab.com'), - syncGitLabRepositories: vi.fn(), + syncGitLabRepositories: mockSyncGitLabRepositories, })); vi.mock('@roomote/db/server', () => ({ @@ -150,6 +154,9 @@ describe('source-control commands', () => { mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([]); mockRemoveAdoServiceHooksForRepositories.mockResolvedValue([]); mockRemoveGitLabWebhooksForProjects.mockResolvedValue([]); + mockEnsureGitLabWebhooksForProjects.mockResolvedValue([ + { status: 'created', repositoryFullName: 'acme/gitlab-app' }, + ]); mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, { status: 'updated', repositoryFullName: 'Roomote/gitea-api' }, @@ -167,6 +174,48 @@ describe('source-control commands', () => { mockValidateAdoToken.mockResolvedValue({ status: 'valid' }); }); + it('creates GitLab webhooks during the OAuth-triggered repository sync', async () => { + mockSyncGitLabRepositories.mockResolvedValue({ + success: true, + repositories: [ + { + id: 'gitlab-repo-row-1', + externalRepoId: '42', + fullName: 'acme/gitlab-app', + }, + ], + }); + + const result = await syncRepositoriesCommand(buildMockAuth(), { + provider: 'gitlab', + }); + + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: [ + expect.objectContaining({ + name: 'GITLAB_WEBHOOK_SECRET', + value: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ], + }), + ); + expect(mockEnsureGitLabWebhooksForProjects).toHaveBeenCalledWith({ + projects: [{ projectId: '42', repositoryFullName: 'acme/gitlab-app' }], + webhookUrl: 'https://roomote.example.com/api/webhooks/gitlab', + secretToken: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(result).toMatchObject({ + success: true, + webhooks: { + status: 'configured', + created: 1, + skippedUnmapped: 0, + }, + }); + }); + it('syncs Gitea repositories and configures pull request webhooks', async () => { const result = await syncRepositoriesCommand(buildMockAuth(), { provider: 'gitea', diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 46a7787b..69176550 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -79,12 +79,11 @@ export async function getSourceControlConfigStatusCommand( } /** - * Webhooks are only auto-created for synced repositories that are mapped to - * at least one environment. Hooking every project the deployment token can - * administer leaks events from unrelated repositories (for example private - * work projects visible to a personal PAT) to the Roomote URL, so sync - * treats environment mappings as the operator's selection and removes the - * Roomote webhook from synced-but-unmapped repositories. + * Most provider webhooks are only auto-created for synced repositories that + * are mapped to at least one environment. GitLab is the exception because its + * OAuth callback immediately triggers the first repository sync, before + * onboarding creates environment mappings; its provider wrapper opts into + * registering hooks for the OAuth-visible projects. */ async function getEnvironmentMappedRepositoryIds(): Promise> { const mappingRows = await db @@ -153,8 +152,12 @@ async function configureScopedProviderWebhooks< actorUserId: string; logPrefix: string; removalDescription: string; + scopeToEnvironmentMappings?: boolean; }): Promise> { - const mappedRepositoryIds = await getEnvironmentMappedRepositoryIds(); + const scopeToEnvironmentMappings = params.scopeToEnvironmentMappings ?? true; + const mappedRepositoryIds = scopeToEnvironmentMappings + ? await getEnvironmentMappedRepositoryIds() + : new Set(params.repositories.map((repository) => repository.id)); const mappedTargets = params.repositories .filter((repository) => mappedRepositoryIds.has(repository.id)) .flatMap(params.toTarget); @@ -291,6 +294,10 @@ async function configureGitLabWebhooks( actorUserId, logPrefix: '[configureGitLabWebhooks]', removalDescription: 'remove webhooks from unmapped projects', + // GitLab OAuth is followed immediately by the repository sync. At that + // point onboarding has not created environment mappings yet, so register + // hooks for the projects returned by OAuth instead of skipping them. + scopeToEnvironmentMappings: false, }); } From a3f9541856592e7a300b20f4a29b16bdb387eb61 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:05:38 +0100 Subject: [PATCH 21/31] fix GitLab OAuth API authentication --- .../source-control-pull-request-reads.test.ts | 1 + ...source-control-pull-request-shared.test.ts | 23 +++++++++++++++++++ ...source-control-pull-request-writes.test.ts | 1 + .../source-control-pull-requests.test.ts | 1 + .../source-control-pull-request-reads.ts | 5 ++-- .../source-control-pull-request-shared.ts | 10 ++++++++ .../source-control-pull-request-writes.ts | 3 ++- .../source-control-pull-requests.ts | 7 +++--- 8 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts index 7afaefec..25536d32 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts new file mode 100644 index 00000000..0a75ad38 --- /dev/null +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@roomote/gitlab', () => ({ + isGitLabOAuthAccessToken: (token: string) => token === 'oauth-token', +})); + +import { buildGitLabTokenHeader } from '../source-control-pull-request-shared'; + +describe('buildGitLabTokenHeader', () => { + it('uses the Bearer authorization header for OAuth tokens', () => { + expect(buildGitLabTokenHeader('oauth-token')).toEqual({ + name: 'Authorization', + value: 'Bearer oauth-token', + }); + }); + + it('uses the private-token header for non-OAuth tokens', () => { + expect(buildGitLabTokenHeader('glpat-token')).toEqual({ + name: 'PRIVATE-TOKEN', + value: 'glpat-token', + }); + }); +}); diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts index e48b5b94..ccacaf45 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index f3b357cb..278de643 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -45,6 +45,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts index b44f1792..9f94da44 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts @@ -34,6 +34,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, isDraftTitle, @@ -1036,7 +1037,7 @@ async function getGitLabMergeRequestDetails({ `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabMergeRequestDetailsSchema, }); @@ -1106,7 +1107,7 @@ async function listGitLabMergeRequestComments({ `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}/discussions`, { per_page: 100 }, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabDiscussionListSchema, }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts index 4ed9f904..e7dedd6a 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts @@ -12,9 +12,19 @@ import { getSourceControlProviderLabel, type SourceControlProvider, } from '@roomote/types'; +import { isGitLabOAuthAccessToken } from '@roomote/gitlab'; export type FetchImpl = typeof fetch; +export function buildGitLabTokenHeader(token: string): { + name: string; + value: string; +} { + return isGitLabOAuthAccessToken(token) + ? { name: 'Authorization', value: `Bearer ${token}` } + : { name: 'PRIVATE-TOKEN', value: token }; +} + export type RepositoryRow = { id: string; sourceControlProvider: SourceControlProvider; diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts index d5164624..205c5e77 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts @@ -33,6 +33,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, parseAdoRepositoryFullName, @@ -591,7 +592,7 @@ async function writeGitLabMergeRequest({ }): Promise { const { projectId, token, apiBaseUrl } = await resolveGitLabWriteContext(repository); - const tokenHeader = { name: 'PRIVATE-TOKEN', value: token }; + const tokenHeader = buildGitLabTokenHeader(token); const mergeRequestPath = `/projects/${encodeURIComponent(projectId)}/merge_requests/${input.prNumber}`; switch (input.action) { diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index 706ac8ea..5262637b 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -47,6 +47,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, isDraftTitle, @@ -588,7 +589,7 @@ async function createOrUpdateGitLabMergeRequest({ )}/merge_requests/${existing.iid}`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), body: { ...common, ...(input.labels.length > 0 @@ -607,7 +608,7 @@ async function createOrUpdateGitLabMergeRequest({ )}/merge_requests`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), body: { source_branch: input.sourceBranch, target_branch: targetBranch, @@ -666,7 +667,7 @@ async function listGitLabMergeRequests({ per_page: 2, }, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabMergeRequestListSchema, }); } From aa9c0a1273f8fa32776083c58f02c3c31afa5d44 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:19:37 +0100 Subject: [PATCH 22/31] chore(web): clean up setup lint exports --- apps/web/src/app/(onboarding)/setup/StepInvoke.tsx | 1 - .../(onboarding)/setup/StepSourceControlConnect.tsx | 12 +++++++++--- apps/web/src/app/(onboarding)/setup/hooks.ts | 2 +- apps/web/src/components/system/primitives/icons.ts | 1 - 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index 5b9d524b..8be8d0a2 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -16,7 +16,6 @@ import { Loader2, ArrowRight, Checkbox, - CornerDownRight, } from '@/components/system'; import { useTRPC } from '@/trpc/client'; import { useEnvironments } from '@/hooks/environments/useEnvironments'; diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx index a29ebf66..6067ab7b 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -213,7 +213,7 @@ export function StepSourceControlConnect({ !giteaOAuthConfigured || oauthAutoSyncMarkerPresent); - const handleSyncRepositories = async () => { + const handleSyncRepositories = useCallback(async () => { if ( provider === 'ado' && adoAuthMode === 'delegated' && @@ -229,7 +229,13 @@ export function StepSourceControlConnect({ } syncRepositories.mutate(); - }; + }, [ + adoAuthMode, + adoLinkedAccount.data?.account, + provider, + saveAdoLinkedAccount, + syncRepositories, + ]); const autoSyncAttempted = useRef(false); useEffect(() => { diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 35f55035..4de1178f 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -17,7 +17,7 @@ import { useSetupAsyncSession } from './setup-session'; import { hasSeenSetupWelcome } from './welcome-seen'; export type OpenRouterOauthEntryStatus = 'connected' | 'error'; -export type SetupStepTransitionDirection = 'forward' | 'backward'; +type SetupStepTransitionDirection = 'forward' | 'backward'; type SetupEntryContext = { step: SetupStep | null; diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index eab1d538..d988b066 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -46,7 +46,6 @@ export { Copy, CopyIcon, CornerDownLeftIcon, - CornerDownRight, Cpu, CreditCardIcon, Database, From ffa01f84c6a525928cf1c4a181eaa819cd93953b Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:31:12 +0100 Subject: [PATCH 23/31] default gitea url --- .../(onboarding)/setup/StepSourceControlConfig.client.test.tsx | 1 + .../web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index ec549b4a..a3936a67 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -269,6 +269,7 @@ describe('StepSourceControlConfig', () => { ); expect(screen.getByText('Gitea Base URL')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://gitea.com')).toBeInTheDocument(); expect(screen.getByText(/Gitea OAuth Client ID/)).toBeInTheDocument(); expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); expect(screen.getByText(/Gitea 1\.23\+/)).toBeInTheDocument(); diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 9710048d..13a2af50 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -34,6 +34,7 @@ import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; +const DEFAULT_GITEA_BASE_URL = 'https://gitea.com'; type SourceControlField = SetupSourceControlStatus['providers'][number]['fields'][number]; @@ -57,6 +58,8 @@ function getNonSecretFieldInitialValues( next[field.envVarName] = savedValue; } else if (field.envVarName === 'GITLAB_BASE_URL') { next[field.envVarName] = DEFAULT_GITLAB_BASE_URL; + } else if (field.envVarName === 'GITEA_BASE_URL') { + next[field.envVarName] = DEFAULT_GITEA_BASE_URL; } } From 0c071dc5ac6ed0d5ce137ed830f56ace9de31009 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:34:15 +0100 Subject: [PATCH 24/31] fix gitea oauth webhook registration --- .../commands/source-control/index.test.ts | 38 ++++++++++--------- .../src/trpc/commands/source-control/index.ts | 12 ++++-- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index cd0397e6..32b53b5d 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -255,7 +255,7 @@ describe('source-control commands', () => { }); }); - it('only hooks environment-mapped repositories and removes hooks from unmapped ones', async () => { + it('hooks all Gitea repositories returned by OAuth', async () => { mockSyncGiteaRepositories.mockResolvedValue({ success: true, repositories: [ @@ -265,9 +265,7 @@ describe('source-control commands', () => { }); mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, - ]); - mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([ - { status: 'removed', repositoryFullName: 'acme/private-work-repo' }, + { status: 'created', repositoryFullName: 'acme/private-work-repo' }, ]); const result = await syncRepositoriesCommand(buildMockAuth(), { @@ -276,45 +274,49 @@ describe('source-control commands', () => { expect(mockEnsureGiteaWebhooksForRepositories).toHaveBeenCalledWith( expect.objectContaining({ - repositories: [{ repositoryFullName: 'Roomote/gitea-app' }], + repositories: [ + { repositoryFullName: 'Roomote/gitea-app' }, + { repositoryFullName: 'acme/private-work-repo' }, + ], }), ); - expect(mockRemoveGiteaWebhooksForRepositories).toHaveBeenCalledWith({ - repositories: [{ repositoryFullName: 'acme/private-work-repo' }], - webhookUrl: 'https://roomote.example.com/api/webhooks/gitea', - }); + expect(mockRemoveGiteaWebhooksForRepositories).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, webhooks: { status: 'configured', - created: 1, + created: 2, updated: 0, failed: [], - skippedUnmapped: 1, - removed: 1, + skippedUnmapped: 0, + removed: 0, }, }); }); - it('does not create webhooks when no synced repository is environment-mapped', async () => { + it('creates webhooks when no synced repository is environment-mapped', async () => { mockEnvironmentMappingRows.rows = []; - mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([ - { status: 'not_found', repositoryFullName: 'Roomote/gitea-app' }, + mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ + { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, ]); const result = await syncRepositoriesCommand(buildMockAuth(), { provider: 'gitea', }); - expect(mockEnsureGiteaWebhooksForRepositories).not.toHaveBeenCalled(); + expect(mockEnsureGiteaWebhooksForRepositories).toHaveBeenCalledWith( + expect.objectContaining({ + repositories: [{ repositoryFullName: 'Roomote/gitea-app' }], + }), + ); expect(result).toMatchObject({ success: true, webhooks: { status: 'configured', - created: 0, + created: 1, updated: 0, failed: [], - skippedUnmapped: 1, + skippedUnmapped: 0, removed: 0, }, }); diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 69176550..534c2e88 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -80,10 +80,10 @@ export async function getSourceControlConfigStatusCommand( /** * Most provider webhooks are only auto-created for synced repositories that - * are mapped to at least one environment. GitLab is the exception because its - * OAuth callback immediately triggers the first repository sync, before - * onboarding creates environment mappings; its provider wrapper opts into - * registering hooks for the OAuth-visible projects. + * are mapped to at least one environment. GitLab and Gitea are exceptions + * because their OAuth callbacks immediately trigger the first repository + * sync, before onboarding creates environment mappings; their provider + * wrappers opt into registering hooks for the OAuth-visible projects. */ async function getEnvironmentMappedRepositoryIds(): Promise> { const mappingRows = await db @@ -353,6 +353,10 @@ async function configureGiteaWebhooks( actorUserId, logPrefix: '[configureGiteaWebhooks]', removalDescription: 'remove webhooks from unmapped repositories', + // Gitea OAuth is followed immediately by the repository sync, before + // onboarding creates environment mappings. Register hooks for the + // repositories returned by OAuth instead of skipping them. + scopeToEnvironmentMappings: false, }); } From 7c4b1ef209186188895d41ede8a33c0ddd9e7122 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:36:42 +0100 Subject: [PATCH 25/31] invoke --- apps/web/src/app/(onboarding)/invokeMethods.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/(onboarding)/invokeMethods.tsx b/apps/web/src/app/(onboarding)/invokeMethods.tsx index dcd8b340..f2bdfb13 100644 --- a/apps/web/src/app/(onboarding)/invokeMethods.tsx +++ b/apps/web/src/app/(onboarding)/invokeMethods.tsx @@ -51,7 +51,6 @@ const sourceControlProviderCopy: Record< { icon: string; description: string; - example?: string; } > = { github: { @@ -61,11 +60,10 @@ const sourceControlProviderCopy: Record< gitlab: { icon: 'gitlab', description: `Mention @roomote in a comment on any merge request.`, - example: '@roomote address the merge request feedback above', }, gitea: { icon: 'gitea', - description: `Start work from connected Gitea pull requests and repositories.`, + description: `Mention @roomote in a comment on any pull request.`, }, bitbucket: { icon: 'bitbucket', From 1a00ab3109f227b5855aa4edfa01c445f86769d7 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 17:39:26 +0100 Subject: [PATCH 26/31] fix(source-control): resolve CI test and CodeQL findings --- .../src/app/(onboarding)/invokeMethods.tsx | 6 +-- .../setup/StepInvoke.client.test.tsx | 7 --- .../StepSourceControlConfig.client.test.tsx | 2 +- .../src/trpc/commands/setup-new/index.test.ts | 49 +++++++------------ .../commands/source-control/index.test.ts | 12 ++--- packages/gitea/src/api.ts | 6 +-- packages/gitlab/src/api.ts | 6 +-- .../src/setup-source-control-config.test.ts | 22 +++++---- 8 files changed, 42 insertions(+), 68 deletions(-) diff --git a/apps/web/src/app/(onboarding)/invokeMethods.tsx b/apps/web/src/app/(onboarding)/invokeMethods.tsx index f2bdfb13..1dd8d8cd 100644 --- a/apps/web/src/app/(onboarding)/invokeMethods.tsx +++ b/apps/web/src/app/(onboarding)/invokeMethods.tsx @@ -148,11 +148,7 @@ export function buildInvokeMethods({ icon: createBrandIcon(copy.icon, title), title, description, - ...(identity?.examplePrompt - ? { example: identity.examplePrompt } - : copy.example - ? { example: copy.example } - : {}), + ...(identity?.examplePrompt ? { example: identity.examplePrompt } : {}), }; }), ...(includeLinear diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index 6aa27f35..c62a19ae 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -337,10 +337,6 @@ describe('Setup StepInvoke', () => { expect( screen.getByText('Mention @roomote in a comment on any PR.'), ).toBeInTheDocument(); - - expect( - screen.getByText('@roomote address the PR feedback above'), - ).toBeInTheDocument(); }); it('clarifies that GitLab mentions work on any merge request', () => { @@ -349,9 +345,6 @@ describe('Setup StepInvoke', () => { expect( screen.getByText('Mention @roomote in a comment on any merge request.'), ).toBeInTheDocument(); - expect( - screen.getByText('@roomote address the merge request feedback above'), - ).toBeInTheDocument(); }); it('shows configured providers with automations before the web UI', () => { diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index a3936a67..8f4ec42f 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -251,7 +251,7 @@ describe('StepSourceControlConfig', () => { screen.getAllByText(/GitLab OAuth application/).length, ).toBeGreaterThan(0); expect( - screen.getByText(/In GitLab, create an OAuth application/), + screen.getByText(/In GitLab, click on your avatar/), ).toBeInTheDocument(); expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index bba979da..8a8e6e94 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -14,7 +14,7 @@ const { mockAcquireComputeProvisioningLock, mockResolveSavedWorkerImage, mockResolveGiteaBaseUrl, - mockValidateGiteaToken, + mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ mockTxSelect: vi.fn(), mockDbTransaction: vi.fn(), @@ -32,7 +32,7 @@ const { mockResolveGiteaBaseUrl: vi .fn() .mockResolvedValue('https://gitea.example.com'), - mockValidateGiteaToken: vi.fn().mockResolvedValue({ status: 'valid' }), + mockResolveDeploymentEnvVar: vi.fn().mockResolvedValue(null), })); vi.mock('../compute/compute-provisioning', async (importOriginal) => { @@ -54,7 +54,6 @@ vi.mock('@roomote/gitea', () => ({ normalizeGiteaBaseUrl: (value: string) => value.startsWith('http') ? value : `https://${value}`, resolveGiteaBaseUrl: mockResolveGiteaBaseUrl, - validateGiteaToken: mockValidateGiteaToken, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -100,6 +99,7 @@ vi.mock('@roomote/db/server', () => ({ isNull: vi.fn(), markTaskStartParallelCountEndedAt: vi.fn(), resolveSavedWorkerImage: mockResolveSavedWorkerImage, + resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, purgeSavedDeploymentWorkerImage: vi.fn(async () => undefined), resolveTelegramRuntimeCredentials: vi.fn(async () => ({ botToken: null, @@ -491,7 +491,7 @@ describe('setup-new source-control config commands', () => { beforeEach(() => { vi.clearAllMocks(); mockResolveGiteaBaseUrl.mockResolvedValue('https://gitea.example.com'); - mockValidateGiteaToken.mockResolvedValue({ status: 'valid' }); + mockResolveDeploymentEnvVar.mockResolvedValue(null); mockDbTransaction.mockImplementation(async (callback) => { const tx = { @@ -535,16 +535,13 @@ describe('setup-new source-control config commands', () => { provider: 'gitea', values: { GITEA_BASE_URL: 'gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }, ); expect(result.setupNewState.sourceControlProvider).toBe('gitea'); - expect(mockValidateGiteaToken).toHaveBeenCalledWith({ - token: 'gitea-token', - baseUrl: 'https://gitea.example.com', - }); expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -554,7 +551,8 @@ describe('setup-new source-control config commands', () => { name: 'GITEA_BASE_URL', value: 'https://gitea.example.com', }), - expect.objectContaining({ name: 'GITEA_TOKEN' }), + expect.objectContaining({ name: 'GITEA_CLIENT_ID' }), + expect.objectContaining({ name: 'GITEA_CLIENT_SECRET' }), ]), }), ); @@ -565,8 +563,16 @@ describe('setup-new source-control config commands', () => { mockTxSelect .mockReturnValueOnce(createSelectChain([{ setupNewState: {} }])) .mockReturnValueOnce( - createFromOnlySelectChain([{ name: 'GITEA_TOKEN' }]), + createFromOnlySelectChain([ + { name: 'GITEA_CLIENT_ID' }, + { name: 'GITEA_CLIENT_SECRET' }, + ]), ); + mockResolveDeploymentEnvVar.mockImplementation(async (name: string) => + name === 'GITEA_CLIENT_ID' || name === 'GITEA_CLIENT_SECRET' + ? 'saved-value' + : null, + ); await saveSetupNewSourceControlConfigCommand(buildMockAuth(), { provider: 'gitea', @@ -592,30 +598,11 @@ describe('setup-new source-control config commands', () => { }, }), ).rejects.toThrow( - 'Enter the required Gitea configuration values to continue.', + 'Configure the Gitea OAuth client ID and secret to continue.', ); expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - - it('rejects invalid Gitea tokens before saving config', async () => { - mockValidateGiteaToken.mockResolvedValue({ - status: 'invalid', - error: 'Gitea rejected the token.', - }); - - await expect( - saveSetupNewSourceControlConfigCommand(buildMockAuth(), { - provider: 'gitea', - values: { - GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', - }, - }), - ).rejects.toThrow('Gitea rejected the token.'); - - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); - }); }); describe('setup-new compute config commands', () => { diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 32b53b5d..c58108e6 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -410,20 +410,16 @@ describe('source-control commands', () => { expect(mockValidateAdoToken).not.toHaveBeenCalled(); }); - it('rejects invalid Gitea tokens during config validation', async () => { - mockValidateGiteaToken.mockResolvedValue({ - status: 'invalid', - error: 'Gitea rejected the token.', - }); - + it('requires Gitea OAuth credentials during config validation', async () => { await expect( assertValidSourceControlConfigInput({ provider: 'gitea', values: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', }, }), - ).rejects.toThrow('Gitea rejected the token.'); + ).rejects.toThrow( + 'Configure the Gitea OAuth client ID and secret to continue.', + ); }); }); diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index c7ac0214..1a44fee1 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -110,12 +110,12 @@ export function normalizeGiteaBaseUrl(baseUrl: string): string { const url = new URL( /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, ); - const apiPathSuffix = /\/api\/v1$/; + const apiPathSuffix = '/api/v1'; if (url.hostname === 'gitea.com') { url.pathname = '/'; - } else if (apiPathSuffix.test(url.pathname)) { - url.pathname = url.pathname.replace(apiPathSuffix, '') || '/'; + } else if (url.pathname.endsWith(apiPathSuffix)) { + url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; } return url.toString().replace(/\/+$/, ''); diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 1c1c92ff..b58890cf 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -122,12 +122,12 @@ export function normalizeGitLabBaseUrl(baseUrl: string): string { const url = new URL( /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, ); - const apiPathSuffix = /\/api\/v4$/; + const apiPathSuffix = '/api/v4'; if (url.hostname === 'gitlab.com') { url.pathname = '/'; - } else if (apiPathSuffix.test(url.pathname)) { - url.pathname = url.pathname.replace(apiPathSuffix, '') || '/'; + } else if (url.pathname.endsWith(apiPathSuffix)) { + url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; } return url.toString().replace(/\/+$/, ''); diff --git a/packages/types/src/setup-source-control-config.test.ts b/packages/types/src/setup-source-control-config.test.ts index 541650c0..473340ec 100644 --- a/packages/types/src/setup-source-control-config.test.ts +++ b/packages/types/src/setup-source-control-config.test.ts @@ -150,7 +150,8 @@ describe('buildSetupSourceControlStatus', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }); @@ -274,16 +275,17 @@ describe('buildSetupSourceControlStatus', () => { }, }); const gitlab = gitlabStatus.providers.find((p) => p.provider === 'gitlab'); - const optionalGitLabClientId = gitlab?.fields.find( + const requiredGitLabClientId = gitlab?.fields.find( (field) => field.envVarName === 'GITLAB_CLIENT_ID', ); - const optionalGitLabClientSecret = gitlab?.fields.find( + const requiredGitLabClientSecret = gitlab?.fields.find( (field) => field.envVarName === 'GITLAB_CLIENT_SECRET', ); const giteaStatus = buildSetupSourceControlStatus({ runtimeEnv: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }); const gitea = giteaStatus.providers.find((p) => p.provider === 'gitea'); @@ -291,16 +293,16 @@ describe('buildSetupSourceControlStatus', () => { (field) => field.envVarName === 'GITEA_WEBHOOK_SECRET', ); - expect(optionalGitLabClientId).toMatchObject({ - required: false, - runtimeSatisfied: false, + expect(requiredGitLabClientId).toMatchObject({ + runtimeSatisfied: true, savedSatisfied: false, }); - expect(optionalGitLabClientSecret).toMatchObject({ - required: false, - runtimeSatisfied: false, + expect(requiredGitLabClientSecret).toMatchObject({ + runtimeSatisfied: true, savedSatisfied: false, }); + expect(requiredGitLabClientId?.required).not.toBe(false); + expect(requiredGitLabClientSecret?.required).not.toBe(false); expect(optionalGiteaWebhookSecret).toMatchObject({ required: false, runtimeSatisfied: false, From 561706bf740421065c7d83495a97d99081f5c983 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 18:07:38 +0100 Subject: [PATCH 27/31] fix gitea comment setup guidance --- .../gitea/__tests__/handleComment.test.ts | 23 ++++++++--- .../handlers/gitea/__tests__/index.test.ts | 40 +++++++++++++++++++ apps/api/src/handlers/gitea/handleComment.ts | 37 +++++------------ apps/api/src/handlers/gitea/index.ts | 16 ++++---- .../source-control-account-linking.ts | 20 ++++++++++ 5 files changed, 96 insertions(+), 40 deletions(-) diff --git a/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts b/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts index 541d84c7..b40bf366 100644 --- a/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts @@ -2,7 +2,6 @@ const { mockEnqueueTask, mockGetTaskUrl, mockGetGiteaAutomationTargets, - mockGetGiteaDeploymentUser, mockCreateGiteaPullRequestComment, mockFindActiveGitHubPrReviewTask, mockFindReusableGitHubPrFollowUpOwner, @@ -12,7 +11,6 @@ const { mockEnqueueTask: vi.fn(), mockGetTaskUrl: vi.fn(), mockGetGiteaAutomationTargets: vi.fn(), - mockGetGiteaDeploymentUser: vi.fn(), mockCreateGiteaPullRequestComment: vi.fn(), mockFindActiveGitHubPrReviewTask: vi.fn(), mockFindReusableGitHubPrFollowUpOwner: vi.fn(), @@ -26,7 +24,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ })); vi.mock('@roomote/gitea', () => ({ - getGiteaDeploymentUser: mockGetGiteaDeploymentUser, createGiteaPullRequestComment: mockCreateGiteaPullRequestComment, })); @@ -109,7 +106,6 @@ describe('handleGiteaComment', () => { mockEnqueueTask.mockReset(); mockGetTaskUrl.mockReset(); mockGetGiteaAutomationTargets.mockReset(); - mockGetGiteaDeploymentUser.mockReset(); mockCreateGiteaPullRequestComment.mockReset(); mockFindActiveGitHubPrReviewTask.mockReset(); mockFindReusableGitHubPrFollowUpOwner.mockReset(); @@ -127,7 +123,6 @@ describe('handleGiteaComment', () => { }, ], }); - mockGetGiteaDeploymentUser.mockResolvedValue({ login: 'roomote-bot' }); mockCreateGiteaPullRequestComment.mockResolvedValue({ id: 1 }); mockFindActiveGitHubPrReviewTask.mockResolvedValue(null); mockFindReusableGitHubPrFollowUpOwner.mockResolvedValue(null); @@ -246,6 +241,24 @@ describe('handleGiteaComment', () => { expect(mockEnqueueTask).not.toHaveBeenCalled(); }); + it('replies when no active environment target exists', async () => { + mockGetGiteaAutomationTargets.mockResolvedValue({ + status: 'error', + message: + 'no environment mapping associated with [gitea:123, acme/backend]', + }); + + const result = await handleGiteaComment(makeCommentPayload()); + + expect(result).toEqual({ status: 'ok', message: 'reviewer_gate_miss' }); + expect(mockCreateGiteaPullRequestComment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('settings/environments'), + }), + ); + expect(mockEnqueueTask).not.toHaveBeenCalled(); + }); + it('handles issue comment payloads that only include issue PR context', async () => { const result = await handleGiteaComment( makeCommentPayload({ diff --git a/apps/api/src/handlers/gitea/__tests__/index.test.ts b/apps/api/src/handlers/gitea/__tests__/index.test.ts index fca8e771..b43cfb03 100644 --- a/apps/api/src/handlers/gitea/__tests__/index.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/index.test.ts @@ -21,6 +21,7 @@ vi.mock('@roomote/db/server', () => ({ vi.mock('../../logging', () => ({ apiLogger: { debug: vi.fn(), + info: vi.fn(), }, logApiError: vi.fn(), })); @@ -222,6 +223,45 @@ describe('gitea webhook router', () => { ); }); + it('routes issue_comment webhooks when Gitea omits is_pull', async () => { + const payload = { + action: 'created', + sender: { id: 7, login: 'alice' }, + repository: { + id: 123, + full_name: 'acme/backend', + html_url: 'https://git.example.com/acme/backend', + }, + issue: { + number: 42, + title: 'Update backend', + }, + comment: { + id: 900, + body: 'Hey @roomote please take a look', + user: { id: 7, login: 'alice' }, + }, + }; + const body = JSON.stringify(payload); + + const response = await app.request('http://localhost/api/webhooks/gitea', { + method: 'POST', + headers: { + 'x-gitea-signature': sign(body), + 'x-gitea-event': 'issue_comment', + 'x-gitea-delivery': 'delivery-comment-3', + }, + body, + }); + + expect(response.status).toBe(200); + expect(mockHandleGiteaComment).toHaveBeenCalledWith( + expect.objectContaining({ + issue: expect.objectContaining({ number: 42 }), + }), + ); + }); + it('rejects invalid signatures', async () => { const response = await app.request('http://localhost/api/webhooks/gitea', { method: 'POST', diff --git a/apps/api/src/handlers/gitea/handleComment.ts b/apps/api/src/handlers/gitea/handleComment.ts index 94d151e2..7fb315e2 100644 --- a/apps/api/src/handlers/gitea/handleComment.ts +++ b/apps/api/src/handlers/gitea/handleComment.ts @@ -3,10 +3,7 @@ import { findActiveGitHubPrReviewTask, findReusableGitHubPrFollowUpOwner, } from '@roomote/db/server'; -import { - createGiteaPullRequestComment, - getGiteaDeploymentUser, -} from '@roomote/gitea'; +import { createGiteaPullRequestComment } from '@roomote/gitea'; import { type TaskPayload, TaskPayloadKind, @@ -15,7 +12,10 @@ import { } from '@roomote/types'; import type { WebhookResponse } from '../../types'; -import { buildSourceControlAccountLinkRequiredMessage } from '../source-control-account-linking'; +import { + buildSourceControlAccountLinkRequiredMessage, + buildSourceControlEnvironmentRequiredMessage, +} from '../source-control-account-linking'; import { sendMessageToTask, steerMessageToTask, @@ -42,23 +42,6 @@ function isGiteaMention(commentBody: string): boolean { return commentBody.toLowerCase().includes(GITEA_MENTION_HANDLE); } -async function isDeploymentTokenAuthor(username: string): Promise { - try { - const deploymentUser = await getGiteaDeploymentUser(); - - return ( - !!deploymentUser && - deploymentUser.login.toLowerCase() === username.toLowerCase() - ); - } catch (error) { - console.warn( - `[handleGiteaComment] failed to resolve Gitea deployment token identity: ${error instanceof Error ? error.message : String(error)}`, - ); - - return false; - } -} - async function postMentionResponseComment({ repositoryFullName, pullRequestNumber, @@ -224,10 +207,7 @@ export async function handleGiteaComment( return { status: 'ok', message: 'no_comment_author' }; } - if ( - isRoomoteGiteaUsername(commenter) || - (await isDeploymentTokenAuthor(commenter)) - ) { + if (isRoomoteGiteaUsername(commenter)) { return { status: 'ok', message: 'roomote_authored_comment' }; } @@ -265,7 +245,10 @@ export async function handleGiteaComment( targetsResult.status === 'error' && targetsResult.code === 'account_link_required' ? buildSourceControlAccountLinkRequiredMessage('gitea') - : buildReviewerGateMissComment(), + : targetsResult.status === 'error' && + targetsResult.message.includes('no environment mapping') + ? buildSourceControlEnvironmentRequiredMessage('gitea') + : buildReviewerGateMissComment(), }); return { diff --git a/apps/api/src/handlers/gitea/index.ts b/apps/api/src/handlers/gitea/index.ts index 0d64cbdb..81b1dc30 100644 --- a/apps/api/src/handlers/gitea/index.ts +++ b/apps/api/src/handlers/gitea/index.ts @@ -62,20 +62,20 @@ gitea.post('/', async (c) => { const eventName = getGiteaEventName(headers); const deliveryId = getGiteaDeliveryId({ body, headers }); - if ( - eventName === 'pull_request_comment' || - (eventName === 'issue_comment' && - typeof parsedJson === 'object' && - parsedJson !== null && - (parsedJson as { is_pull?: unknown }).is_pull === true) - ) { + if (eventName === 'pull_request_comment' || eventName === 'issue_comment') { const payload = giteaPullRequestCommentWebhookSchema.parse(parsedJson); await recordWebhook( deliveryId, `${eventName}.${payload.action}`, payload, - () => handleGiteaComment(payload), + async () => { + const result = await handleGiteaComment(payload); + apiLogger.info?.( + `[Gitea] ${eventName}.${payload.action} delivery ${deliveryId}: ${result.message ?? result.status}`, + ); + return result; + }, { provider: 'gitea' }, ); diff --git a/apps/api/src/handlers/source-control-account-linking.ts b/apps/api/src/handlers/source-control-account-linking.ts index 59d1e6ae..4da01730 100644 --- a/apps/api/src/handlers/source-control-account-linking.ts +++ b/apps/api/src/handlers/source-control-account-linking.ts @@ -56,6 +56,26 @@ function getLinkedAccountsSettingsUrl( } } +function getEnvironmentsSettingsUrl(): string | null { + try { + return new URL('/settings/environments', Env.R_APP_URL).toString(); + } catch { + return null; + } +} + +export function buildSourceControlEnvironmentRequiredMessage( + provider: SourceControlCommentProvider, +): string { + const copy = sourceControlCommentProviderCopy[provider]; + const settingsUrl = getEnvironmentsSettingsUrl(); + const settingsText = settingsUrl + ? `[Settings -> Environments](${settingsUrl})` + : 'Settings -> Environments'; + + return `I saw the mention, but no Roomote environment is mapped to this ${copy.accountLabel} repository. Set up an environment and map this repository from ${settingsText}, then mention me again.`; +} + export function buildSourceControlAccountLinkRequiredMessage( provider: SourceControlCommentProvider, ): string { From 42eca047962ce3f5d7fa0e48aac3d78707f3f097 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 19:15:00 +0100 Subject: [PATCH 28/31] fix gitea oauth callback setup --- .../(onboarding)/setup/GiteaSourceControlConfig.tsx | 12 ++++++++++-- .../setup/StepSourceControlConfig.client.test.tsx | 6 +++++- .../source-control/gitea/oauth/authorize/route.ts | 9 +++++---- .../api/source-control/gitea/oauth/callback/route.ts | 7 ++++--- .../lib/server/__tests__/get-callback-host.test.ts | 12 ++++++++++++ apps/web/src/lib/server/get-callback-host.ts | 6 ++++-- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx index db677aa7..d1da2f80 100644 --- a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx @@ -10,11 +10,19 @@ export function GiteaSourceControlInstructions({

Configure the Gitea application.

-

Enter copy the redirect URL below:

+

+ Add both redirect URIs below to the Gitea application. The first is for + deployment authorization; the second is for linking individual Gitea + accounts in Roomote. +

+ ); } diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 8f4ec42f..abaceb57 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -273,7 +273,11 @@ describe('StepSourceControlConfig', () => { expect(screen.getByText(/Gitea OAuth Client ID/)).toBeInTheDocument(); expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); expect(screen.getByText(/Gitea 1\.23\+/)).toBeInTheDocument(); - expect(screen.getByText('Web redirect URI')).toBeInTheDocument(); + expect(screen.getByText('Deployment callback')).toBeInTheDocument(); + expect(screen.getByText('Account linking callback')).toBeInTheDocument(); + expect( + screen.getByText(/api\/auth\/oauth2\/callback\/gitea/), + ).toBeInTheDocument(); expect(screen.queryByText('Gitea Access Token')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); diff --git a/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts index 120028e5..19ff2012 100644 --- a/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts +++ b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; import { @@ -6,13 +6,13 @@ import { createGiteaOAuthAuthorizationUrl, resolveGiteaBaseUrl, } from '@roomote/gitea'; -import { authorize } from '@/lib/server'; +import { authorize, getCallbackHost } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +export async function GET(request: NextRequest) { const authResult = await authorize(); const webEnv = await bootstrapWebRuntimeEnv(); if (!authResult.success || !authResult.isAdmin) { @@ -28,7 +28,8 @@ export async function GET() { { status: 400 }, ); } - const redirectUri = buildGiteaOAuthRedirectUri(webEnv.R_APP_URL); + const callbackOrigin = new URL(getCallbackHost(request)).origin; + const redirectUri = buildGiteaOAuthRedirectUri(callbackOrigin); const { url, state } = createGiteaOAuthAuthorizationUrl({ baseUrl, clientId, diff --git a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts index 0741ba02..13bfbd0d 100644 --- a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts @@ -6,7 +6,7 @@ import { exchangeGiteaOAuthCode, resolveGiteaBaseUrl, } from '@roomote/gitea'; -import { authorize } from '@/lib/server'; +import { authorize, getCallbackHost } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; export const runtime = 'nodejs'; @@ -14,7 +14,8 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const webEnv = await bootstrapWebRuntimeEnv(); - const redirect = new URL('/setup', webEnv.R_APP_URL); + const callbackOrigin = new URL(getCallbackHost(request)).origin; + const redirect = new URL('/setup', callbackOrigin); redirect.searchParams.set('step', 'source-control-connect'); const response = () => NextResponse.redirect(redirect); const authResult = await authorize(); @@ -45,7 +46,7 @@ export async function GET(request: NextRequest) { clientId, clientSecret, code, - redirectUri: buildGiteaOAuthRedirectUri(webEnv.R_APP_URL), + redirectUri: buildGiteaOAuthRedirectUri(callbackOrigin), }); redirect.searchParams.set('gitea', 'connected'); redirect.searchParams.set('sync', '1'); diff --git a/apps/web/src/lib/server/__tests__/get-callback-host.test.ts b/apps/web/src/lib/server/__tests__/get-callback-host.test.ts index 185c1700..b9497bb4 100644 --- a/apps/web/src/lib/server/__tests__/get-callback-host.test.ts +++ b/apps/web/src/lib/server/__tests__/get-callback-host.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; const mockEnvState = vi.hoisted(() => ({ R_APP_URL: 'https://roomote.203-0-113-7.sslip.io', + R_PUBLIC_URL: undefined as string | undefined, })); vi.mock('../env', () => ({ @@ -41,4 +42,15 @@ describe('getCallbackHost', () => { 'https://roomote.example.com/api/slack/callback?code=abc', ); }); + + it('prefers the configured public URL for internal requests', () => { + mockEnvState.R_PUBLIC_URL = 'https://roomote.example.com'; + const request = new NextRequest( + 'http://localhost:13000/api/gitea/callback', + ); + + expect(getCallbackHost(request)).toBe( + 'https://roomote.example.com/api/gitea/callback', + ); + }); }); diff --git a/apps/web/src/lib/server/get-callback-host.ts b/apps/web/src/lib/server/get-callback-host.ts index 3c47bd9b..27ccbb65 100644 --- a/apps/web/src/lib/server/get-callback-host.ts +++ b/apps/web/src/lib/server/get-callback-host.ts @@ -20,9 +20,11 @@ export function getCallbackHost(request: NextRequest) { // Rewrite any internal origin (any scheme/port) to the configured public // URL so OAuth redirects land back on the host the user's browser can // actually reach (e.g. the ngrok or self-host public domain) instead of - // https://localhost:13000 or http://0.0.0.0:3000. + // https://localhost:13000 or http://0.0.0.0:3000. Prefer R_PUBLIC_URL when + // R_APP_URL is intentionally left at its local development default. if (INTERNAL_HOSTNAMES.has(url.hostname)) { - return `${Env.R_APP_URL}${url.pathname}${url.search}`; + const publicUrl = Env.R_PUBLIC_URL ?? Env.R_APP_URL; + return `${publicUrl}${url.pathname}${url.search}`; } return request.url; From b4a83fbadbd7b7c89f70be869523dba3bbffb0a0 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 14 Jul 2026 19:19:33 +0100 Subject: [PATCH 29/31] address source control oauth review feedback --- .../app/(onboarding)/onboarding/StepInvoke.tsx | 5 +++++ .../src/app/(onboarding)/setup/StepInvoke.tsx | 7 ++++++- packages/gitea/src/__tests__/oauth.test.ts | 15 ++++++++++++++- packages/gitea/src/oauth.ts | 2 +- packages/gitlab/src/__tests__/api.test.ts | 2 ++ packages/gitlab/src/__tests__/oauth.test.ts | 18 ++++++++++++++++++ packages/gitlab/src/api.ts | 10 ++++++++++ packages/gitlab/src/oauth.ts | 2 +- 8 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/gitlab/src/__tests__/oauth.test.ts diff --git a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx index 6e1f62aa..fdfa9c2c 100644 --- a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx @@ -86,6 +86,11 @@ export function StepInvoke({

{method.description}

+ {method.example ? ( +

+ Example: {method.example} +

+ ) : null} ))} diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index 8be8d0a2..f70845b3 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -183,11 +183,16 @@ export function StepInvoke({ {methods.map((method) => (
-
+

{method.title}: {method.description}

+ {method.example ? ( +

+ Example: {method.example} +

+ ) : null}
))} diff --git a/packages/gitea/src/__tests__/oauth.test.ts b/packages/gitea/src/__tests__/oauth.test.ts index aaa0af1e..5b53aae4 100644 --- a/packages/gitea/src/__tests__/oauth.test.ts +++ b/packages/gitea/src/__tests__/oauth.test.ts @@ -31,9 +31,22 @@ describe('Gitea deployment OAuth', () => { 'https://roomote.example/api/source-control/gitea/oauth/callback', ); expect(url.origin).toBe('https://git.example'); - expect(url.pathname).toBe('/login/oauth/authorize'); + expect(url.pathname).toBe('/gitea/login/oauth/authorize'); expect(url.searchParams.get('client_id')).toBe('client-id'); expect(url.searchParams.get('state')).toBe('deployment-state'); expect(url.searchParams.get('scope')).toBe(getGiteaOAuthScopes().join(' ')); }); + + it('preserves a self-managed Gitea base path in the authorization URL', () => { + const result = createGiteaOAuthAuthorizationUrl({ + baseUrl: 'https://git.example/gitea', + clientId: 'client-id', + redirectUri: 'https://roomote.example/callback', + state: 'state', + }); + + expect(new URL(result.url).toString()).toContain( + 'https://git.example/gitea/login/oauth/authorize?', + ); + }); }); diff --git a/packages/gitea/src/oauth.ts b/packages/gitea/src/oauth.ts index b6739b06..028ce337 100644 --- a/packages/gitea/src/oauth.ts +++ b/packages/gitea/src/oauth.ts @@ -64,7 +64,7 @@ export function createGiteaOAuthAuthorizationUrl(input: { }): { url: string; state: string } { const state = input.state ?? randomBytes(32).toString('hex'); const url = new URL( - '/login/oauth/authorize', + 'login/oauth/authorize', `${input.baseUrl.replace(/\/$/, '')}/`, ); url.searchParams.set('client_id', input.clientId); diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index e464ba61..89a21372 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -345,6 +345,7 @@ describe('createTaskRunScopedGitLabTokens', () => { credentials: [ { host: 'gitlab.com', + originBaseUrl: 'https://gitlab.com', repositoryFullName: 'group/project', username: 'oauth2', token: 'glptt_repo_scoped', @@ -645,6 +646,7 @@ describe('createTaskRunScopedGitLabTokens', () => { proxyCredentials: [ { host: 'gitlab.com', + originBaseUrl: 'https://gitlab.com', repositoryFullName: 'group/project', username: 'oauth2', token: 'oauth_access_token', diff --git a/packages/gitlab/src/__tests__/oauth.test.ts b/packages/gitlab/src/__tests__/oauth.test.ts new file mode 100644 index 00000000..55c13a43 --- /dev/null +++ b/packages/gitlab/src/__tests__/oauth.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { createGitLabOAuthAuthorizationUrl } from '../oauth'; + +describe('GitLab deployment OAuth', () => { + it('preserves a self-managed GitLab base path in the authorization URL', () => { + const result = createGitLabOAuthAuthorizationUrl({ + baseUrl: 'https://git.example/gitlab', + clientId: 'client-id', + redirectUri: 'https://roomote.example/callback', + state: 'state', + }); + + expect(new URL(result.url).toString()).toContain( + 'https://git.example/gitlab/oauth/authorize?', + ); + }); +}); diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index b58890cf..290a04e0 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -80,6 +80,7 @@ export type GitLabScopedProjectTokenDescriptor = z.infer< >; export type GitLabScopedProjectTokenCredential = { host: string; + originBaseUrl: string; repositoryFullName: string; username: string; token: string; @@ -948,6 +949,7 @@ async function createScopedProjectToken(params: { apiBaseUrl?: string; fetchImpl?: typeof fetch; host: string; + originBaseUrl: string; projectId: string; repositoryFullName: string; runId: number; @@ -975,6 +977,7 @@ async function createScopedProjectToken(params: { return { credential: { host: params.host, + originBaseUrl: params.originBaseUrl, repositoryFullName: params.repositoryFullName, username: data.username?.trim() || 'oauth2', token: data.token, @@ -991,6 +994,7 @@ async function rotateScopedProjectToken(params: { apiBaseUrl?: string; fetchImpl?: typeof fetch; host: string; + originBaseUrl: string; descriptor: GitLabScopedProjectTokenDescriptor; token: string; }): Promise<{ @@ -1013,6 +1017,7 @@ async function rotateScopedProjectToken(params: { return { credential: { host: params.host, + originBaseUrl: params.originBaseUrl, repositoryFullName: params.descriptor.repositoryFullName, username: data.username?.trim() || 'oauth2', token: data.token, @@ -1114,6 +1119,7 @@ export async function createTaskRunScopedGitLabTokens( credentials: [], proxyCredentials: repositoriesList.map((repository) => ({ host, + originBaseUrl: baseUrl, repositoryFullName: repository.repositoryFullName, username: 'oauth2', token: deploymentToken, @@ -1156,6 +1162,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, descriptor: existingDescriptor, token: deploymentToken, }).catch( @@ -1164,6 +1171,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, projectId: repository.projectId, repositoryFullName: repository.repositoryFullName, runId: taskRun.id, @@ -1174,6 +1182,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, projectId: repository.projectId, repositoryFullName: repository.repositoryFullName, runId: taskRun.id, @@ -1202,6 +1211,7 @@ export async function createTaskRunScopedGitLabTokens( credentials: [], proxyCredentials: repositoriesList.map((repository) => ({ host, + originBaseUrl: baseUrl, repositoryFullName: repository.repositoryFullName, username: 'oauth2', token: deploymentToken, diff --git a/packages/gitlab/src/oauth.ts b/packages/gitlab/src/oauth.ts index 2f15081e..1a5ee1cf 100644 --- a/packages/gitlab/src/oauth.ts +++ b/packages/gitlab/src/oauth.ts @@ -58,7 +58,7 @@ export function createGitLabOAuthAuthorizationUrl(input: { }): { url: string; state: string } { const state = input.state ?? randomBytes(32).toString('hex'); const url = new URL( - '/oauth/authorize', + 'oauth/authorize', `${input.baseUrl.replace(/\/$/, '')}/`, ); url.searchParams.set('client_id', input.clientId); From e8bfcfec9c8a15b55da2d7cb6a73e0f16336ebb3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 14 Jul 2026 18:35:17 +0000 Subject: [PATCH 30/31] fix: address remaining source control review feedback --- .../handlers/gitea/__tests__/index.test.ts | 13 ++++++----- apps/api/src/handlers/gitea/index.ts | 6 +++++ apps/docs/providers/source-control/gitea.mdx | 8 ++++--- .../setup/GiteaSourceControlConfig.tsx | 9 ++------ .../StepSourceControlConfig.client.test.tsx | 9 ++++++-- .../setup/sourceControlSetupCopy.ts | 1 + .../src/components/settings/SourceControl.tsx | 22 +++++++++++-------- 7 files changed, 42 insertions(+), 26 deletions(-) diff --git a/apps/api/src/handlers/gitea/__tests__/index.test.ts b/apps/api/src/handlers/gitea/__tests__/index.test.ts index b43cfb03..a0c14faa 100644 --- a/apps/api/src/handlers/gitea/__tests__/index.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/index.test.ts @@ -223,7 +223,7 @@ describe('gitea webhook router', () => { ); }); - it('routes issue_comment webhooks when Gitea omits is_pull', async () => { + it('ignores issue_comment webhooks when Gitea omits is_pull', async () => { const payload = { action: 'created', sender: { id: 7, login: 'alice' }, @@ -255,11 +255,14 @@ describe('gitea webhook router', () => { }); expect(response.status).toBe(200); - expect(mockHandleGiteaComment).toHaveBeenCalledWith( - expect.objectContaining({ - issue: expect.objectContaining({ number: 42 }), - }), + expect(mockRecordWebhook).toHaveBeenCalledWith( + 'delivery-comment-3', + 'issue_comment.created', + expect.objectContaining({ action: 'created' }), + expect.any(Function), + { provider: 'gitea' }, ); + expect(mockHandleGiteaComment).not.toHaveBeenCalled(); }); it('rejects invalid signatures', async () => { diff --git a/apps/api/src/handlers/gitea/index.ts b/apps/api/src/handlers/gitea/index.ts index 81b1dc30..e7f8ae6b 100644 --- a/apps/api/src/handlers/gitea/index.ts +++ b/apps/api/src/handlers/gitea/index.ts @@ -64,12 +64,18 @@ gitea.post('/', async (c) => { if (eventName === 'pull_request_comment' || eventName === 'issue_comment') { const payload = giteaPullRequestCommentWebhookSchema.parse(parsedJson); + const isPullRequestComment = + eventName === 'pull_request_comment' || payload.is_pull === true; await recordWebhook( deliveryId, `${eventName}.${payload.action}`, payload, async () => { + if (!isPullRequestComment) { + return { status: 'ok', message: 'not_pull_request_comment' }; + } + const result = await handleGiteaComment(payload); apiLogger.info?.( `[Gitea] ${eventName}.${payload.action} delivery ${deliveryId}: ${result.message ?? result.status}`, diff --git a/apps/docs/providers/source-control/gitea.mdx b/apps/docs/providers/source-control/gitea.mdx index 6fc47649..9ef7afc1 100644 --- a/apps/docs/providers/source-control/gitea.mdx +++ b/apps/docs/providers/source-control/gitea.mdx @@ -82,9 +82,11 @@ existing PR owner task when possible, or enqueue a new review task. ## Current limits -Personal Gitea account linking reuses the same OAuth application but stores a -separate `authAccounts` grant with only `read:user`; the deployment service -account grant is never used to identify a pull-request commenter. +Gitea OAuth applications accept one redirect URL. The setup flow configures the +deployment callback, so the same OAuth application cannot also provide personal +Gitea account linking. Pull-request comment mentions still use the Gitea webhook +sender identity and never use the deployment service-account grant to identify +the commenter. ## Verify setup diff --git a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx index d1da2f80..e66e904c 100644 --- a/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx @@ -11,18 +11,13 @@ export function GiteaSourceControlInstructions({ Configure the Gitea application.

- Add both redirect URIs below to the Gitea application. The first is for - deployment authorization; the second is for linking individual Gitea - accounts in Roomote. + Add this redirect URI to the Gitea application for deployment + authorization.

-
); } diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index abaceb57..c81408fd 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -274,10 +274,15 @@ describe('StepSourceControlConfig', () => { expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); expect(screen.getByText(/Gitea 1\.23\+/)).toBeInTheDocument(); expect(screen.getByText('Deployment callback')).toBeInTheDocument(); - expect(screen.getByText('Account linking callback')).toBeInTheDocument(); expect( - screen.getByText(/api\/auth\/oauth2\/callback\/gitea/), + screen.getByText(/api\/source-control\/gitea\/oauth\/callback/), ).toBeInTheDocument(); + expect( + screen.queryByText('Account linking callback'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/api\/auth\/oauth2\/callback\/gitea/), + ).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Access Token')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index a21cb263..e8dabb65 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -18,6 +18,7 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { + creationHref: 'https://gitlab.com/-/user_settings/applications', setupLabel: 'GitLab OAuth application', creationHint: 'In GitLab, click on your avatar → Edit Profile → Applications → Add new application, granting the API read/write scope.', diff --git a/apps/web/src/components/settings/SourceControl.tsx b/apps/web/src/components/settings/SourceControl.tsx index 76686e4d..a94dc6ce 100644 --- a/apps/web/src/components/settings/SourceControl.tsx +++ b/apps/web/src/components/settings/SourceControl.tsx @@ -889,15 +889,19 @@ function ProviderSetupInstructions({

Create {setupCopy.setupLabelArticle ?? 'a'}{' '} - - {setupCopy.setupLabel} - - {' '} + {setupCopy.creationHref ? ( + + {setupCopy.setupLabel} + + + ) : ( + setupCopy.setupLabel + )}{' '} for {title}.

From 623d44e744d40b286740000ea84dc03111e9ea26 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 14 Jul 2026 18:43:55 +0000 Subject: [PATCH 31/31] fix: avoid polynomial URL normalization regexes --- packages/gitea/src/__tests__/api.test.ts | 3 +++ packages/gitea/src/api.ts | 14 ++++++++++++-- packages/gitlab/src/__tests__/api.test.ts | 3 +++ packages/gitlab/src/api.ts | 14 ++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/gitea/src/__tests__/api.test.ts b/packages/gitea/src/__tests__/api.test.ts index 8eb4faab..9f029921 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -127,6 +127,9 @@ describe('Gitea API helpers', () => { expect(normalizeGiteaBaseUrl('git.example.com/')).toBe( 'https://git.example.com', ); + expect(normalizeGiteaBaseUrl('git.example.com////////')).toBe( + 'https://git.example.com', + ); }); it('lists authenticated Gitea repositories with token auth and pagination', async () => { diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 1a44fee1..4cb021e4 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -100,8 +100,18 @@ export type GiteaWebhookEnsureResult = { error?: string; }; +function removeTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return value.slice(0, end); +} + export function normalizeGiteaBaseUrl(baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); + const trimmed = removeTrailingSlashes(baseUrl.trim()); if (!trimmed) { throw new Error('GITEA_BASE_URL cannot be empty.'); @@ -118,7 +128,7 @@ export function normalizeGiteaBaseUrl(baseUrl: string): string { url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; } - return url.toString().replace(/\/+$/, ''); + return removeTrailingSlashes(url.toString()); } export async function resolveGiteaToken(): Promise { diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 89a21372..2ab8dba1 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -131,6 +131,9 @@ describe('resolveGitLabBaseUrl', () => { expect(normalizeGitLabBaseUrl('gitlab.com/roomote/')).toBe( 'https://gitlab.com', ); + expect(normalizeGitLabBaseUrl('gitlab.example.com////////')).toBe( + 'https://gitlab.example.com', + ); }); }); diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 290a04e0..4c6b93dc 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -113,8 +113,18 @@ export type ListGitLabProjectsOptions = { stopAfter?: number; }; +function removeTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return value.slice(0, end); +} + export function normalizeGitLabBaseUrl(baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); + const trimmed = removeTrailingSlashes(baseUrl.trim()); if (!trimmed) { throw new Error('GITLAB_BASE_URL cannot be empty.'); @@ -131,7 +141,7 @@ export function normalizeGitLabBaseUrl(baseUrl: string): string { url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; } - return url.toString().replace(/\/+$/, ''); + return removeTrailingSlashes(url.toString()); } export async function resolveGitLabToken(): Promise {