+ Authorize the GitLab application with the dedicated service
+ account before syncing repositories.
+
+ Authorize GitLab
+
+
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' && (
+
+ )}
{(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}
-
- )}