diff --git a/.env.production.example b/.env.production.example index e3b93e188..fb596f06b 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/api/src/handlers/gitea/__tests__/handleComment.test.ts b/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts index 541d84c7a..b40bf3663 100644 --- a/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/handleComment.test.ts @@ -2,7 +2,6 @@ const { mockEnqueueTask, mockGetTaskUrl, mockGetGiteaAutomationTargets, - mockGetGiteaDeploymentUser, mockCreateGiteaPullRequestComment, mockFindActiveGitHubPrReviewTask, mockFindReusableGitHubPrFollowUpOwner, @@ -12,7 +11,6 @@ const { mockEnqueueTask: vi.fn(), mockGetTaskUrl: vi.fn(), mockGetGiteaAutomationTargets: vi.fn(), - mockGetGiteaDeploymentUser: vi.fn(), mockCreateGiteaPullRequestComment: vi.fn(), mockFindActiveGitHubPrReviewTask: vi.fn(), mockFindReusableGitHubPrFollowUpOwner: vi.fn(), @@ -26,7 +24,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ })); vi.mock('@roomote/gitea', () => ({ - getGiteaDeploymentUser: mockGetGiteaDeploymentUser, createGiteaPullRequestComment: mockCreateGiteaPullRequestComment, })); @@ -109,7 +106,6 @@ describe('handleGiteaComment', () => { mockEnqueueTask.mockReset(); mockGetTaskUrl.mockReset(); mockGetGiteaAutomationTargets.mockReset(); - mockGetGiteaDeploymentUser.mockReset(); mockCreateGiteaPullRequestComment.mockReset(); mockFindActiveGitHubPrReviewTask.mockReset(); mockFindReusableGitHubPrFollowUpOwner.mockReset(); @@ -127,7 +123,6 @@ describe('handleGiteaComment', () => { }, ], }); - mockGetGiteaDeploymentUser.mockResolvedValue({ login: 'roomote-bot' }); mockCreateGiteaPullRequestComment.mockResolvedValue({ id: 1 }); mockFindActiveGitHubPrReviewTask.mockResolvedValue(null); mockFindReusableGitHubPrFollowUpOwner.mockResolvedValue(null); @@ -246,6 +241,24 @@ describe('handleGiteaComment', () => { expect(mockEnqueueTask).not.toHaveBeenCalled(); }); + it('replies when no active environment target exists', async () => { + mockGetGiteaAutomationTargets.mockResolvedValue({ + status: 'error', + message: + 'no environment mapping associated with [gitea:123, acme/backend]', + }); + + const result = await handleGiteaComment(makeCommentPayload()); + + expect(result).toEqual({ status: 'ok', message: 'reviewer_gate_miss' }); + expect(mockCreateGiteaPullRequestComment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('settings/environments'), + }), + ); + expect(mockEnqueueTask).not.toHaveBeenCalled(); + }); + it('handles issue comment payloads that only include issue PR context', async () => { const result = await handleGiteaComment( makeCommentPayload({ diff --git a/apps/api/src/handlers/gitea/__tests__/index.test.ts b/apps/api/src/handlers/gitea/__tests__/index.test.ts index fca8e7719..a0c14faad 100644 --- a/apps/api/src/handlers/gitea/__tests__/index.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/index.test.ts @@ -21,6 +21,7 @@ vi.mock('@roomote/db/server', () => ({ vi.mock('../../logging', () => ({ apiLogger: { debug: vi.fn(), + info: vi.fn(), }, logApiError: vi.fn(), })); @@ -222,6 +223,48 @@ describe('gitea webhook router', () => { ); }); + it('ignores issue_comment webhooks when Gitea omits is_pull', async () => { + const payload = { + action: 'created', + sender: { id: 7, login: 'alice' }, + repository: { + id: 123, + full_name: 'acme/backend', + html_url: 'https://git.example.com/acme/backend', + }, + issue: { + number: 42, + title: 'Update backend', + }, + comment: { + id: 900, + body: 'Hey @roomote please take a look', + user: { id: 7, login: 'alice' }, + }, + }; + const body = JSON.stringify(payload); + + const response = await app.request('http://localhost/api/webhooks/gitea', { + method: 'POST', + headers: { + 'x-gitea-signature': sign(body), + 'x-gitea-event': 'issue_comment', + 'x-gitea-delivery': 'delivery-comment-3', + }, + body, + }); + + expect(response.status).toBe(200); + expect(mockRecordWebhook).toHaveBeenCalledWith( + 'delivery-comment-3', + 'issue_comment.created', + expect.objectContaining({ action: 'created' }), + expect.any(Function), + { provider: 'gitea' }, + ); + expect(mockHandleGiteaComment).not.toHaveBeenCalled(); + }); + it('rejects invalid signatures', async () => { const response = await app.request('http://localhost/api/webhooks/gitea', { method: 'POST', diff --git a/apps/api/src/handlers/gitea/handleComment.ts b/apps/api/src/handlers/gitea/handleComment.ts index 94d151e2b..7fb315e2a 100644 --- a/apps/api/src/handlers/gitea/handleComment.ts +++ b/apps/api/src/handlers/gitea/handleComment.ts @@ -3,10 +3,7 @@ import { findActiveGitHubPrReviewTask, findReusableGitHubPrFollowUpOwner, } from '@roomote/db/server'; -import { - createGiteaPullRequestComment, - getGiteaDeploymentUser, -} from '@roomote/gitea'; +import { createGiteaPullRequestComment } from '@roomote/gitea'; import { type TaskPayload, TaskPayloadKind, @@ -15,7 +12,10 @@ import { } from '@roomote/types'; import type { WebhookResponse } from '../../types'; -import { buildSourceControlAccountLinkRequiredMessage } from '../source-control-account-linking'; +import { + buildSourceControlAccountLinkRequiredMessage, + buildSourceControlEnvironmentRequiredMessage, +} from '../source-control-account-linking'; import { sendMessageToTask, steerMessageToTask, @@ -42,23 +42,6 @@ function isGiteaMention(commentBody: string): boolean { return commentBody.toLowerCase().includes(GITEA_MENTION_HANDLE); } -async function isDeploymentTokenAuthor(username: string): Promise { - try { - const deploymentUser = await getGiteaDeploymentUser(); - - return ( - !!deploymentUser && - deploymentUser.login.toLowerCase() === username.toLowerCase() - ); - } catch (error) { - console.warn( - `[handleGiteaComment] failed to resolve Gitea deployment token identity: ${error instanceof Error ? error.message : String(error)}`, - ); - - return false; - } -} - async function postMentionResponseComment({ repositoryFullName, pullRequestNumber, @@ -224,10 +207,7 @@ export async function handleGiteaComment( return { status: 'ok', message: 'no_comment_author' }; } - if ( - isRoomoteGiteaUsername(commenter) || - (await isDeploymentTokenAuthor(commenter)) - ) { + if (isRoomoteGiteaUsername(commenter)) { return { status: 'ok', message: 'roomote_authored_comment' }; } @@ -265,7 +245,10 @@ export async function handleGiteaComment( targetsResult.status === 'error' && targetsResult.code === 'account_link_required' ? buildSourceControlAccountLinkRequiredMessage('gitea') - : buildReviewerGateMissComment(), + : targetsResult.status === 'error' && + targetsResult.message.includes('no environment mapping') + ? buildSourceControlEnvironmentRequiredMessage('gitea') + : buildReviewerGateMissComment(), }); return { diff --git a/apps/api/src/handlers/gitea/index.ts b/apps/api/src/handlers/gitea/index.ts index 0d64cbdbf..e7f8ae6b9 100644 --- a/apps/api/src/handlers/gitea/index.ts +++ b/apps/api/src/handlers/gitea/index.ts @@ -62,20 +62,26 @@ gitea.post('/', async (c) => { const eventName = getGiteaEventName(headers); const deliveryId = getGiteaDeliveryId({ body, headers }); - if ( - eventName === 'pull_request_comment' || - (eventName === 'issue_comment' && - typeof parsedJson === 'object' && - parsedJson !== null && - (parsedJson as { is_pull?: unknown }).is_pull === true) - ) { + if (eventName === 'pull_request_comment' || eventName === 'issue_comment') { const payload = giteaPullRequestCommentWebhookSchema.parse(parsedJson); + const isPullRequestComment = + eventName === 'pull_request_comment' || payload.is_pull === true; await recordWebhook( deliveryId, `${eventName}.${payload.action}`, payload, - () => handleGiteaComment(payload), + async () => { + if (!isPullRequestComment) { + return { status: 'ok', message: 'not_pull_request_comment' }; + } + + const result = await handleGiteaComment(payload); + apiLogger.info?.( + `[Gitea] ${eventName}.${payload.action} delivery ${deliveryId}: ${result.message ?? result.status}`, + ); + return result; + }, { provider: 'gitea' }, ); diff --git a/apps/api/src/handlers/source-control-account-linking.ts b/apps/api/src/handlers/source-control-account-linking.ts index 59d1e6aed..4da017306 100644 --- a/apps/api/src/handlers/source-control-account-linking.ts +++ b/apps/api/src/handlers/source-control-account-linking.ts @@ -56,6 +56,26 @@ function getLinkedAccountsSettingsUrl( } } +function getEnvironmentsSettingsUrl(): string | null { + try { + return new URL('/settings/environments', Env.R_APP_URL).toString(); + } catch { + return null; + } +} + +export function buildSourceControlEnvironmentRequiredMessage( + provider: SourceControlCommentProvider, +): string { + const copy = sourceControlCommentProviderCopy[provider]; + const settingsUrl = getEnvironmentsSettingsUrl(); + const settingsText = settingsUrl + ? `[Settings -> Environments](${settingsUrl})` + : 'Settings -> Environments'; + + return `I saw the mention, but no Roomote environment is mapped to this ${copy.accountLabel} repository. Set up an environment and map this repository from ${settingsText}, then mention me again.`; +} + export function buildSourceControlAccountLinkRequiredMessage( provider: SourceControlCommentProvider, ): string { diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 0e3c30ea1..5f91e4c51 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 personal access token for source-control setup. | -| `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/bitbucket.mdx b/apps/docs/providers/source-control/bitbucket.mdx index 30678a743..3f3943091 100644 --- a/apps/docs/providers/source-control/bitbucket.mdx +++ b/apps/docs/providers/source-control/bitbucket.mdx @@ -22,13 +22,13 @@ the workspaces or repositories Roomote should use. In that account's choose **Create API token with scopes**, select **Bitbucket** as the app, and grant these scopes: -| Scope | Needed for | -| ----- | ---------- | -| read:repository:bitbucket and write:repository:bitbucket | cloning, branch creation, and commits | -| read:pullrequest:bitbucket and write:pullrequest:bitbucket | pull request workflows and comments | -| read:webhook:bitbucket and write:webhook:bitbucket | automatic webhook setup during repository sync | -| read:workspace:bitbucket | workspace discovery during repository sync | -| read:user:bitbucket | identity validation when saving credentials | +| Scope | Needed for | +| ---------------------------------------------------------- | ---------------------------------------------- | +| read:repository:bitbucket and write:repository:bitbucket | cloning, branch creation, and commits | +| read:pullrequest:bitbucket and write:pullrequest:bitbucket | pull request workflows and comments | +| read:webhook:bitbucket and write:webhook:bitbucket | automatic webhook setup during repository sync | +| read:workspace:bitbucket | workspace discovery during repository sync | +| read:user:bitbucket | identity validation when saving credentials | Save the token and the Atlassian account email in setup, deployment env vars, or local `.env.local`: @@ -52,6 +52,10 @@ git-over-HTTPS, Roomote automatically uses Bitbucket's static `BITBUCKET_BASE_URL` defaults to `https://bitbucket.org`. Only Bitbucket Cloud is accepted; other hosts are rejected. +The setup flow asks only for the API token and its Atlassian account email. +Roomote uses the Bitbucket Cloud URL and generates the webhook secret +automatically; optional OAuth settings remain available under Settings. + ## Sync repositories After the Bitbucket values are available, open Settings, go to the Environments diff --git a/apps/docs/providers/source-control/gitea.mdx b/apps/docs/providers/source-control/gitea.mdx index 913712ee9..9ef7afc17 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,18 +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 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 @@ -50,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: @@ -76,9 +82,11 @@ 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. +Gitea OAuth applications accept one redirect URL. The setup flow configures the +deployment callback, so the same OAuth application cannot also provide personal +Gitea account linking. Pull-request comment mentions still use the Gitea webhook +sender identity and never use the deployment service-account grant to identify +the commenter. ## Verify setup diff --git a/apps/docs/providers/source-control/gitlab.mdx b/apps/docs/providers/source-control/gitlab.mdx index 92b425e85..09d3320f3 100644 --- a/apps/docs/providers/source-control/gitlab.mdx +++ b/apps/docs/providers/source-control/gitlab.mdx @@ -4,38 +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 support uses a deployment-owned token. There is no self-serve GitLab App -or OAuth installation flow yet, so an operator provides a GitLab token, syncs -repositories from Settings, and configures webhooks when review automation is -needed. +GitLab does not have a GitHub-style App installation flow. 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 bot token +## Create a GitLab OAuth application -Create a dedicated GitLab bot or service-account user and add it to the groups -or projects Roomote should access. Create an access token for that identity -with scopes that match the behavior you want: +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`. ## 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. @@ -69,32 +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. That linking flow requires a GitLab OAuth application, so -create one even though the fields are optional: - -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)/invokeMethods.tsx b/apps/web/src/app/(onboarding)/invokeMethods.tsx index 14ea7ce0c..1dd8d8cde 100644 --- a/apps/web/src/app/(onboarding)/invokeMethods.tsx +++ b/apps/web/src/app/(onboarding)/invokeMethods.tsx @@ -27,7 +27,6 @@ const communicationProviderCopy: Record< icon: string; title: string; description: string; - example?: string; } > = { slack: { @@ -52,7 +51,6 @@ const sourceControlProviderCopy: Record< { icon: string; description: string; - example?: string; } > = { github: { @@ -61,11 +59,11 @@ 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.`, }, gitea: { icon: 'gitea', - description: `Start work from connected Gitea pull requests and repositories.`, + description: `Mention @roomote in a comment on any pull request.`, }, bitbucket: { icon: 'bitbucket', diff --git a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx index 94d7cc984..fdfa9c2c9 100644 --- a/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/StepInvoke.tsx @@ -86,11 +86,11 @@ export function StepInvoke({

{method.description}

- {'example' in method && ( -

- {method.example} + {method.example ? ( +

+ Example: {method.example}

- )} + ) : null} ))} diff --git a/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx index d052767ba..50fea71ea 100644 --- a/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/GitLabSourceControlConfig.tsx @@ -1,39 +1,20 @@ -import { Button, CopyIconButton, ExternalLink } from '@/components/system'; +import { InstructionUrl } from './ProviderSetupInstructions'; -export function GitLabSourceControlConfig({ - applicationsUrl, - redirectUri, +export function GitLabSourceControlInstructions({ + publicOrigin, }: { - applicationsUrl: string; - redirectUri: string; + publicOrigin: string; }) { return ( -
-
-

- Recommended: create a GitLab OAuth application. - -

-

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

-

- {redirectUri} - -

-

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

-
+
+

+ Configure the GitLab OAuth application. +

+

Copy the redirect URL below into GitLab:

+
); } 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 000000000..e66e904ce --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/GiteaSourceControlConfig.tsx @@ -0,0 +1,23 @@ +import { InstructionUrl } from './ProviderSetupInstructions'; + +export function GiteaSourceControlInstructions({ + publicOrigin, +}: { + publicOrigin: string; +}) { + return ( +
+

+ Configure the Gitea application. +

+

+ Add this redirect URI to the Gitea application for deployment + authorization. +

+ +
+ ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index b6709b19f..c62a19aed 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -337,9 +337,13 @@ describe('Setup StepInvoke', () => { expect( screen.getByText('Mention @roomote in a comment on any PR.'), ).toBeInTheDocument(); + }); + + it('clarifies that GitLab mentions work on any merge request', () => { + render(); expect( - screen.getByText('@roomote address the PR feedback above'), + screen.getByText('Mention @roomote in a comment on any merge request.'), ).toBeInTheDocument(); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index 9ec048c20..f70845b38 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -16,7 +16,6 @@ import { Loader2, ArrowRight, Checkbox, - CornerDownRight, } from '@/components/system'; import { useTRPC } from '@/trpc/client'; import { useEnvironments } from '@/hooks/environments/useEnvironments'; @@ -184,17 +183,16 @@ export function StepInvoke({ {methods.map((method) => (
-
+

{method.title}: {method.description}

- {'example' in method && ( -

- - {method.example} + {method.example ? ( +

+ Example: {method.example}

- )} + ) : null}
))} 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 74fff52dc..c81408fde 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 Personal Access Token', - secret: true, + envVarName: 'GITLAB_CLIENT_ID', + acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], + label: 'GitLab OAuth Client ID', runtimeSatisfied: false, savedSatisfied: false, satisfiedByEnvVarName: null, @@ -127,6 +126,35 @@ function buildSourceControlSetup( }; } +function buildCatalogProviderSetup( + provider: 'gitlab' | 'gitea' | 'bitbucket', +): SetupSourceControlStatus { + const descriptor = SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.find( + (candidate) => candidate.provider === provider, + )!; + + return buildSourceControlSetup({ + preselectedProvider: provider, + providers: [ + { + ...descriptor, + runtimeConfigSatisfied: false, + savedConfigSatisfied: false, + configSatisfied: false, + configSatisfiedByRuntimeEnv: false, + connected: false, + repositoryCount: 0, + fields: descriptor.fields.map((field) => ({ + ...field, + runtimeSatisfied: false, + savedSatisfied: false, + satisfiedByEnvVarName: null, + })), + }, + ], + }); +} + describe('StepSourceControlConfig', () => { beforeEach(() => { vi.clearAllMocks(); @@ -210,23 +238,83 @@ describe('StepSourceControlConfig', () => { ).toBeInTheDocument(); }); - it('keeps token-backed providers on the existing config form', () => { + it('guides GitLab OAuth application setup', () => { render( , ); expect( - screen.getByText('GitLab Personal Access Token'), + screen.getAllByText(/GitLab OAuth application/).length, + ).toBeGreaterThan(0); + expect( + screen.getByText(/In GitLab, click on your avatar/), ).toBeInTheDocument(); + expect(screen.queryByText(/GitLab Webhook Secret/)).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Create GitHub App' }), ).not.toBeInTheDocument(); }); + it('guides Gitea OAuth application setup', () => { + render( + , + ); + + expect(screen.getByText('Gitea Base URL')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://gitea.com')).toBeInTheDocument(); + expect(screen.getByText(/Gitea OAuth Client ID/)).toBeInTheDocument(); + expect(screen.getByText(/Gitea OAuth Client Secret/)).toBeInTheDocument(); + expect(screen.getByText(/Gitea 1\.23\+/)).toBeInTheDocument(); + expect(screen.getByText('Deployment callback')).toBeInTheDocument(); + expect( + screen.getByText(/api\/source-control\/gitea\/oauth\/callback/), + ).toBeInTheDocument(); + expect( + screen.queryByText('Account linking callback'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/api\/auth\/oauth2\/callback\/gitea/), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Gitea Access Token')).not.toBeInTheDocument(); + expect(screen.queryByText('Gitea Username')).not.toBeInTheDocument(); + expect(screen.queryByText('Gitea Webhook Secret')).not.toBeInTheDocument(); + }); + + it('guides Bitbucket token creation without optional credentials', () => { + render( + , + ); + + expect(screen.getByText('Bitbucket API Token')).toBeInTheDocument(); + expect(screen.getByText('Atlassian Account Email')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Open/ })).toHaveAttribute( + 'href', + 'https://id.atlassian.com/manage-profile/security/api-tokens', + ); + expect( + screen.getByText(/Create API token with scopes → Bitbucket/), + ).toBeInTheDocument(); + expect(screen.queryByText('Bitbucket Base URL')).not.toBeInTheDocument(); + expect( + screen.queryByText('Bitbucket OAuth Client ID'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Bitbucket Webhook Secret'), + ).not.toBeInTheDocument(); + }); + function buildAdoSourceControlSetup( fieldOverrides: Partial< SetupSourceControlStatus['providers'][number]['fields'][number] diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 60bb6f18f..13a2af500 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import Link from 'next/link'; import { toast } from 'sonner'; import { getSetupSourceControlVisibleFields, @@ -29,10 +28,13 @@ import { DEFAULT_ADO_AUTH_MODE, } from './AdoSourceControlConfig'; import { GitHubSourceControlConfig } from './GitHubSourceControlConfig'; -import { GitLabSourceControlConfig } from './GitLabSourceControlConfig'; +import { GiteaSourceControlInstructions } from './GiteaSourceControlConfig'; +import { GitLabSourceControlInstructions } from './GitLabSourceControlConfig'; import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; +const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; +const DEFAULT_GITEA_BASE_URL = 'https://gitea.com'; type SourceControlField = SetupSourceControlStatus['providers'][number]['fields'][number]; @@ -54,6 +56,10 @@ 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; + } else if (field.envVarName === 'GITEA_BASE_URL') { + next[field.envVarName] = DEFAULT_GITEA_BASE_URL; } } @@ -75,6 +81,27 @@ function filterValuesToFields( return next; } +function normalizeGitLabSetupUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed) { + return 'https://gitlab.com'; + } + + try { + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + if (url.hostname === 'gitlab.com') { + url.pathname = '/'; + } else if (/\/api\/v4$/.test(url.pathname)) { + url.pathname = url.pathname.replace(/\/api\/v4$/, '') || '/'; + } + return url.toString().replace(/\/+$/, ''); + } catch { + return 'https://gitlab.com'; + } +} + export function StepSourceControlConfig({ sourceControlSetup, selectedProviderId, @@ -97,6 +124,9 @@ export function StepSourceControlConfig({ >({}); const [showManualGitHubValues, setShowManualGitHubValues] = useState(false); 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, ); @@ -149,7 +179,11 @@ export function StepSourceControlConfig({ const visibleFields = useMemo( () => getSetupSourceControlVisibleFields(providerFields, { - showAdvancedConfig: isAdo && showAdoAdvancedConfig, + showAdvancedConfig: + (isAdo && showAdoAdvancedConfig) || + (selectedProvider?.provider === 'gitlab' && + showGitlabAdvancedConfig) || + (selectedProvider?.provider === 'gitea' && showGiteaAdvancedConfig), }).filter((field) => !isAdo ? true @@ -159,7 +193,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 @@ -186,6 +228,8 @@ export function StepSourceControlConfig({ setEditingSavedValues({}); setShowManualGitHubValues(false); setShowAdoAdvancedConfig(false); + setShowGitlabAdvancedConfig(false); + setShowGiteaAdvancedConfig(false); const hasAdoEntraCredentials = providerFields.some( (field) => ['ADO_CLIENT_ID', 'ADO_CLIENT_SECRET', 'ADO_TENANT_ID'].includes( @@ -274,6 +318,15 @@ export function StepSourceControlConfig({ : {}), }, }); + + if ( + selectedProvider.provider === 'gitea' || + selectedProvider.provider === 'gitlab' + ) { + window.location.assign( + `/api/source-control/${selectedProvider.provider}/oauth/authorize`, + ); + } }; const provider = selectedProvider?.label; @@ -281,23 +334,20 @@ export function StepSourceControlConfig({ ? getSourceControlSetupCopy(selectedProvider.provider) : null; const providerSetupLabel = providerSetupCopy?.setupLabel ?? 'source control'; - const isGitLab = selectedProvider?.provider === 'gitlab'; const publicOrigin = typeof window === 'undefined' ? 'https://your-deployment-url' : window.location.origin; - const gitlabRedirectUri = `${publicOrigin}/api/auth/oauth2/callback/gitlab`; - const typedGitLabBaseUrl = - values['GITLAB_BASE_URL']?.trim().replace(/\/+$/, '') ?? ''; - const configuredGitLabBaseUrl = - sourceControlSetup.gitlabBaseUrl?.trim().replace(/\/+$/, '') ?? ''; - const effectiveGitLabBaseUrl = /^https?:\/\//.test(typedGitLabBaseUrl) - ? typedGitLabBaseUrl - : /^https?:\/\//.test(configuredGitLabBaseUrl) - ? configuredGitLabBaseUrl - : 'https://gitlab.com'; - const gitlabApplicationsUrl = `${effectiveGitLabBaseUrl}/-/user_settings/applications`; - const valuesStepNumber = isGitLab ? 3 : 2; + const rawGitLabBaseUrl = + values['GITLAB_BASE_URL']?.trim() || + sourceControlSetup.gitlabBaseUrl?.trim() || + ''; + const creationHref = + selectedProvider?.provider === 'gitlab' + ? rawGitLabBaseUrl + ? `${normalizeGitLabSetupUrl(rawGitLabBaseUrl)}/-/user_settings/applications` + : undefined + : providerSetupCopy?.creationHref; if (selectedProvider?.provider === 'github' && !showManualGitHubValues) { return ( @@ -321,15 +371,18 @@ export function StepSourceControlConfig({ {providerSetupCopy ? ( <> Create a new {providerSetupCopy.setupLabel}. - + {creationHref && ( + + )} ) : ( <>Create a new {providerSetupLabel}. @@ -340,16 +393,6 @@ export function StepSourceControlConfig({ {providerSetupCopy.creationHint}

) : null} -

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

) : ( @@ -363,25 +406,32 @@ export function StepSourceControlConfig({ )} - {isAdo ? ( + {isAdo || + selectedProvider?.provider === 'gitea' || + selectedProvider?.provider === 'gitlab' ? ( - - - ) : null} - - {isGitLab ? ( - - + {isAdo ? ( + + ) : selectedProvider?.provider === 'gitea' ? ( + + ) : ( + + )} ) : null} - +

Enter the values below for your {provider ?? 'source control'}{' '} integration. @@ -432,20 +482,39 @@ export function StepSourceControlConfig({ } /> ))} - {isAdo && advancedFields.length > 0 ? ( + {(isAdo || + selectedProvider?.provider === 'gitlab' || + selectedProvider?.provider === 'gitea') && + advancedFields.length > 0 ? (

) : null} - {isAdo && showAdoAdvancedConfig + {(isAdo + ? showAdoAdvancedConfig + : selectedProvider?.provider === 'gitlab' + ? showGitlabAdvancedConfig + : showGiteaAdvancedConfig) && advancedFields.length > 0 ? advancedFields.map((field) => ( { 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 85de0e11d..6067ab7bd 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 { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -53,13 +53,13 @@ function getTokenBackedConnectCopy({ switch (provider) { case 'gitlab': - return 'Sync your GitLab projects so Roomote can access your codebase. Roomote also configures merge request webhooks on the synced projects so it can review merge requests automatically.'; + return 'Sync your GitLab projects so Roomote can access your codebase.'; case 'gitea': - return 'Sync your Gitea repositories so Roomote can access your codebase. Roomote also configures pull request webhooks on the synced repositories so it can review pull requests automatically.'; + return 'Sync your Gitea repositories so Roomote can access your codebase.'; case 'bitbucket': - return 'Sync your Bitbucket repositories so Roomote can access your codebase. Roomote also configures pull request webhooks on the synced repositories so it can review pull requests automatically.'; + return 'Sync your Bitbucket repositories so Roomote can access your codebase.'; case 'ado': - return 'Sync your Azure DevOps repositories so Roomote can access your codebase. Roomote also configures pull request service hooks on the synced repositories so it can review pull requests automatically.'; + return 'Sync your Azure DevOps repositories so Roomote can access your codebase.'; default: return `Sync your ${providerLabel} repositories so Roomote can access your codebase.`; } @@ -173,8 +173,47 @@ export function StepSourceControlConnect({ provider, providerLabel, }); + const gitlabOAuthConfigured = + provider === 'gitlab' && + providerStatus?.fields.some( + (field) => + field.envVarName === 'GITLAB_CLIENT_ID' && + (field.runtimeSatisfied || field.savedSatisfied), + ) && + providerStatus?.fields.some( + (field) => + field.envVarName === 'GITLAB_CLIENT_SECRET' && + (field.runtimeSatisfied || field.savedSatisfied), + ); + const giteaOAuthConfigured = + provider === 'gitea' && + providerStatus?.fields.some( + (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 () => { + 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 = useCallback(async () => { if ( provider === 'ado' && adoAuthMode === 'delegated' && @@ -190,7 +229,36 @@ export function StepSourceControlConnect({ } syncRepositories.mutate(); - }; + }, [ + adoAuthMode, + adoLinkedAccount.data?.account, + provider, + saveAdoLinkedAccount, + syncRepositories, + ]); + + 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/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 74786918f..4de1178f3 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -17,6 +17,7 @@ import { useSetupAsyncSession } from './setup-session'; import { hasSeenSetupWelcome } from './welcome-seen'; export type OpenRouterOauthEntryStatus = 'connected' | 'error'; +type SetupStepTransitionDirection = 'forward' | 'backward'; type SetupEntryContext = { step: SetupStep | null; @@ -261,6 +262,9 @@ export function useSetupFlow( ); const [step, setStep] = useState('welcome'); + const [transitionDirection, setTransitionDirection] = + useState('forward'); + const stepRef = useRef('welcome'); const [entryContext] = useState(() => readUrlEntryContext(), ); @@ -273,6 +277,21 @@ export function useSetupFlow( const setupSession = useSetupAsyncSession({ currentTaskId: status?.setupNewState.onboardingTaskId ?? null, }); + stepRef.current = step; + + const setStepWithTransition = useCallback((nextStep: SetupStep) => { + const currentIndex = SETUP_STEPS.indexOf(stepRef.current); + const nextIndex = SETUP_STEPS.indexOf(nextStep); + + if (nextStep !== stepRef.current) { + setTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } + + stepRef.current = nextStep; + setStep(nextStep); + }, []); const communicationStepResolved = setupSession.session.communicationStep.state === 'skipped' || setupSession.session.communicationStep.state === 'completed'; @@ -613,7 +632,7 @@ export function useSetupFlow( initialized.current = true; const requestedStep = entryContext.step; const resolvedStep = resolveDeepLinkStep(requestedStep); - setStep(resolvedStep); + setStepWithTransition(resolvedStep); if (resolvedStep === requestedStep) { // Valid deep link: keep the canonical `step` in the URL and only drop @@ -629,6 +648,7 @@ export function useSetupFlow( entryContext.step, replaceStepUrl, resolveDeepLinkStep, + setStepWithTransition, setupSession.hydrated, status, ]); @@ -641,23 +661,22 @@ export function useSetupFlow( } const fallbackStep = findNextStep(0); + const currentStep = stepRef.current; - setStep((currentStep) => { - if (isInitialReplayVisit(status)) { - return currentStep; - } - - if (currentStep === pinnedUrlStepRef.current) { - return currentStep; - } - - if (shouldSkip(currentStep)) { - return fallbackStep; - } - - return currentStep; - }); - }, [findNextStep, setupSession.hydrated, shouldSkip, status]); + if ( + !isInitialReplayVisit(status) && + currentStep !== pinnedUrlStepRef.current && + shouldSkip(currentStep) + ) { + setStepWithTransition(fallbackStep); + } + }, [ + findNextStep, + setStepWithTransition, + setupSession.hydrated, + shouldSkip, + status, + ]); // Keep the URL in sync with the active step. User navigation and deep-link // corrections write the URL themselves (recorded in lastUrlStepRef); any @@ -715,12 +734,18 @@ export function useSetupFlow( replaceStepUrl(resolvedStep); } - setStep(resolvedStep); + setStepWithTransition(resolvedStep); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [replaceStepUrl, resolveDeepLinkStep, setupSession.hydrated, status]); + }, [ + replaceStepUrl, + resolveDeepLinkStep, + setStepWithTransition, + setupSession.hydrated, + status, + ]); useEffect(() => { if ( @@ -757,10 +782,10 @@ export function useSetupFlow( // navigation must remain subject to the skip rules on the next status // refresh. pinnedUrlStepRef.current = options.revisit ? nextStep : null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [pushStepUrl, step], + [pushStepUrl, setStepWithTransition, step], ); const previousStep = useMemo( @@ -791,9 +816,9 @@ export function useSetupFlow( navigationHistoryRef.current.push(step); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); - }, [findNextStep, pushStepUrl, step]); + }, [findNextStep, pushStepUrl, setStepWithTransition, step]); const goToNextPostOnboardingStep = useCallback( (forceUnlocked = false) => { @@ -802,10 +827,10 @@ export function useSetupFlow( navigationHistoryRef.current.push(step); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [findNextPostOnboardingStep, pushStepUrl, step], + [findNextPostOnboardingStep, pushStepUrl, setStepWithTransition, step], ); const advancePostOnboardingStep = useCallback( @@ -818,14 +843,15 @@ export function useSetupFlow( navigationHistoryRef.current.push(resolvedStep); } pinnedUrlStepRef.current = null; - setStep(nextStep); + setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [findNextPostOnboardingStep, pushStepUrl], + [findNextPostOnboardingStep, pushStepUrl, setStepWithTransition], ); return { step, + transitionDirection, entryContext, goToStep, goToPreviousStep, diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index cef4521de..60f7b2856 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; @@ -93,6 +94,14 @@ function getInitialBootstrapStep(): BootstrapStep { ); } +const BOOTSTRAP_STEPS: readonly BootstrapStep[] = [ + 'welcome', + 'email-account', + 'email-password', + 'auth-provider', + 'auth-env-vars', +]; + export default function SetupPage() { const router = useRouter(); const setupBootstrapOpen = useSetupBootstrapOpen(); @@ -105,6 +114,26 @@ export default function SetupPage() { const [bootstrapStep, setBootstrapStep] = useState( getInitialBootstrapStep, ); + const [bootstrapTransitionDirection, setBootstrapTransitionDirection] = + useState<'forward' | 'backward'>('forward'); + const bootstrapStepRef = useRef(bootstrapStep); + bootstrapStepRef.current = bootstrapStep; + const setBootstrapStepWithTransition = useCallback( + (nextStep: BootstrapStep) => { + const currentIndex = BOOTSTRAP_STEPS.indexOf(bootstrapStepRef.current); + const nextIndex = BOOTSTRAP_STEPS.indexOf(nextStep); + + if (nextStep !== bootstrapStepRef.current) { + setBootstrapTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } + + bootstrapStepRef.current = nextStep; + setBootstrapStep(nextStep); + }, + [], + ); const [pendingAuthProvider, setPendingAuthProvider] = useState(null); const [pendingSourceControlProvider, setPendingSourceControlProvider] = @@ -145,6 +174,7 @@ export default function SetupPage() { ); const { step, + transitionDirection, entryContext, goToStep, goToPreviousStep, @@ -289,26 +319,36 @@ export default function SetupPage() { return; } - setBootstrapStep((currentStep) => { - if (currentStep === 'welcome') { - return currentStep; - } + const currentStep = bootstrapStepRef.current; + let nextStep = currentStep; + if (currentStep !== 'welcome') { if (currentStep === 'email-account' || currentStep === 'email-password') { const nextBootstrapStep = getBootstrapStepAfterWelcome( bootstrapStatus.authSetup, ); - return nextBootstrapStep === 'email-account' - ? currentStep - : nextBootstrapStep; + nextStep = + nextBootstrapStep === 'email-account' + ? currentStep + : nextBootstrapStep; + } else { + nextStep = getNextBootstrapStep( + bootstrapStatus.authSetup, + pendingAuthProvider === 'telegram' ? null : pendingAuthProvider, + ); } + } - return getNextBootstrapStep( - bootstrapStatus.authSetup, - pendingAuthProvider === 'telegram' ? null : pendingAuthProvider, - ); - }); - }, [bootstrapStatus, isSignedIn, pendingAuthProvider, router]); + if (nextStep !== currentStep) { + setBootstrapStepWithTransition(nextStep); + } + }, [ + bootstrapStatus, + isSignedIn, + pendingAuthProvider, + router, + setBootstrapStepWithTransition, + ]); useEffect(() => { if (status?.setupNewState.authProvider) { @@ -352,51 +392,83 @@ export default function SetupPage() { return (
- {bootstrapStep === 'welcome' && ( - { - setBootstrapStep( - getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), - ); - }} - /> - )} - {bootstrapStep === 'email-account' && ( - { - setPendingAuthProvider(provider); - setBootstrapStep( - getNextBootstrapStep(bootstrapStatus.authSetup, provider), - ); + + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.25, ease: 'easeOut' }, + }), }} - onUseEmailPassword={() => setBootstrapStep('email-password')} - /> - )} - {bootstrapStep === 'email-password' && ( - setBootstrapStep('email-account')} - /> - )} - {bootstrapStep === 'auth-provider' && ( - { - if (provider === 'telegram') return; - setPendingAuthProvider(provider); - setBootstrapStep('auth-env-vars'); - }} - onBack={() => setBootstrapStep('email-account')} - /> - )} - {bootstrapStep === 'auth-env-vars' && ( - undefined} - onBack={() => setBootstrapStep('email-account')} - bootstrapMode={true} - setupToken={setupToken} - /> - )} + initial="enter" + animate="center" + exit="exit" + > + {bootstrapStep === 'welcome' && ( + { + setBootstrapStepWithTransition( + getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), + ); + }} + /> + )} + {bootstrapStep === 'email-account' && ( + { + setPendingAuthProvider(provider); + setBootstrapStepWithTransition( + getNextBootstrapStep(bootstrapStatus.authSetup, provider), + ); + }} + onUseEmailPassword={() => + setBootstrapStepWithTransition('email-password') + } + /> + )} + {bootstrapStep === 'email-password' && ( + setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-provider' && ( + { + if (provider === 'telegram') return; + setPendingAuthProvider(provider); + setBootstrapStepWithTransition('auth-env-vars'); + }} + onBack={() => setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-env-vars' && ( + undefined} + onBack={() => setBootstrapStepWithTransition('email-account')} + bootstrapMode={true} + setupToken={setupToken} + /> + )} + +
); } @@ -428,200 +500,228 @@ export default function SetupPage() { return (
- {step === 'welcome' && } - {step === 'auth-provider' && ( - { - setPendingAuthProvider(provider); - goToStep('auth-env-vars'); - }} - onSkip={() => { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - /> - )} - {step === 'auth-env-vars' && - (pendingAuthProvider === 'telegram' ? ( - { - setupSession.setCommunicationStepState('completed'); - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - /> - ) : ( - { - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - bootstrapMode={false} - /> - ))} - {step === 'env-vars' && ( - - )} - {step === 'source-control-provider' && ( - { - saveSourceControlProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveSourceControlProviderChoice.isPending} - /> - )} - {step === 'source-control-config' && ( - { - setPendingSourceControlProvider(null); - goToStep('source-control-connect'); + + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.25, ease: 'easeOut' }, + }), }} - onBack={ - canGoBack - ? () => { - setPendingSourceControlProvider(null); - goToPreviousStep(); + initial="enter" + animate="center" + exit="exit" + > + {step === 'welcome' && } + {step === 'auth-provider' && ( + { + setPendingAuthProvider(provider); + goToStep('auth-env-vars'); + }} + onSkip={() => { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + /> + )} + {step === 'auth-env-vars' && + (pendingAuthProvider === 'telegram' ? ( + { + setupSession.setCommunicationStepState('completed'); + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined } - : undefined - } - /> - )} - {step === 'source-control-connect' && ( - - )} - {step === 'qualification-blocked' && - status.setupQualification.activeBlock ? ( - - ) : null} - {step === 'compute-provider' && ( - { - saveComputeProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveComputeProviderChoice.isPending} - /> - )} - {step === 'compute-config' && ( - { - setPendingComputeProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingComputeProvider(null); - goToPreviousStep(); + /> + ) : ( + { + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined } - : undefined - } - /> - )} - {step === 'slack' && ( - { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - returnPath={getSetupStepPath('slack')} - /> - )} - {step === 'repo-selection' && ( - - goToStep('compute-config', { revisit: true }) - } - onReviewComputeProvider={() => - goToStep('compute-provider', { revisit: true }) - } - onContinue={() => goToStep('invoke')} - onBack={canGoBack ? goToPreviousStep : undefined} - onSkip={() => { - setupSession.unlockPostOnboardingFlow(); - goToNextPostOnboardingStep(true); - }} - /> - )} - {step === 'invoke' && ( - - goToStep('compute-config', { revisit: true }) - } - onTryItOut={() => undefined} - /> - )} + bootstrapMode={false} + /> + ))} + {step === 'env-vars' && ( + + )} + {step === 'source-control-provider' && ( + { + saveSourceControlProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveSourceControlProviderChoice.isPending} + /> + )} + {step === 'source-control-config' && ( + { + setPendingSourceControlProvider(null); + goToStep('source-control-connect'); + }} + onBack={ + canGoBack + ? () => { + setPendingSourceControlProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + )} + {step === 'source-control-connect' && ( + + )} + {step === 'qualification-blocked' && + status.setupQualification.activeBlock ? ( + + ) : null} + {step === 'compute-provider' && ( + { + saveComputeProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveComputeProviderChoice.isPending} + /> + )} + {step === 'compute-config' && ( + { + setPendingComputeProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingComputeProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + )} + {step === 'slack' && ( + { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + returnPath={getSetupStepPath('slack')} + /> + )} + {step === 'repo-selection' && ( + + goToStep('compute-config', { revisit: true }) + } + onReviewComputeProvider={() => + goToStep('compute-provider', { revisit: true }) + } + onContinue={() => goToStep('invoke')} + onBack={canGoBack ? goToPreviousStep : undefined} + onSkip={() => { + setupSession.unlockPostOnboardingFlow(); + goToNextPostOnboardingStep(true); + }} + /> + )} + {step === 'invoke' && ( + + goToStep('compute-config', { revisit: true }) + } + onTryItOut={() => undefined} + /> + )} + +
); } diff --git a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts index c1a31f0c4..e8dabb65b 100644 --- a/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/sourceControlSetupCopy.ts @@ -1,7 +1,8 @@ import type { SourceControlProvider } from '@roomote/types'; type SourceControlSetupCopy = { - creationHref: string; + creationHref?: string; + creationLinkLabel?: string; setupLabel: string; /** Indefinite article for `setupLabel` ("a" unless the label needs "an"). */ setupLabelArticle?: 'a' | 'an'; @@ -17,22 +18,21 @@ const SOURCE_CONTROL_SETUP_COPY: Record< setupLabel: 'GitHub App', }, gitlab: { - creationHref: 'https://gitlab.com/-/user_settings/personal_access_tokens', - setupLabel: 'GitLab access token', + creationHref: 'https://gitlab.com/-/user_settings/applications', + setupLabel: 'GitLab OAuth application', creationHint: - 'Create the token with the api scope. Prefer a bot or service account that is a member of the groups Roomote should access; Roomote syncs its projects and configures merge request webhooks automatically.', + 'In GitLab, click on your avatar → Edit Profile → Applications → Add new application, granting the API read/write scope.', }, gitea: { - creationHref: 'https://docs.gitea.com/development/api-usage', - setupLabel: 'Gitea access token', + setupLabel: 'Gitea OAuth application', creationHint: - 'Create the token with repository access on the instance Roomote should use. Prefer a bot or service account that can administer repository webhooks; Roomote syncs repositories and configures pull request webhooks automatically.', + 'In Gitea 1.23+, go to your org → Settings → Application → New OAuth2 app.', }, bitbucket: { creationHref: 'https://id.atlassian.com/manage-profile/security/api-tokens', setupLabel: 'Bitbucket API token', creationHint: - 'Create an API token with scopes covering repository, pull request, and webhook read/write, plus workspace read (read:workspace:bitbucket) and user read (read:user:bitbucket) so Roomote can discover workspaces and validate the credentials. Prefer a bot or service account that can administer repository webhooks; Roomote syncs repositories and configures pull request webhooks automatically. The Atlassian account email that owns the API token is required.', + 'In Atlassian account settings, select Create API token with scopes → Bitbucket. Grant repository, pull request, and webhook read and write access, plus workspace and user read access. Use a bot or service account that can manage repository webhooks.', }, ado: { creationHref: 'https://dev.azure.com/_usersSettings/tokens', diff --git a/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 000000000..19ff2012f --- /dev/null +++ b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGiteaOAuthRedirectUri, + createGiteaOAuthAuthorizationUrl, + resolveGiteaBaseUrl, +} from '@roomote/gitea'; +import { authorize, getCallbackHost } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + 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 callbackOrigin = new URL(getCallbackHost(request)).origin; + const redirectUri = buildGiteaOAuthRedirectUri(callbackOrigin); + 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 000000000..13bfbd0d7 --- /dev/null +++ b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { + buildGiteaOAuthRedirectUri, + exchangeGiteaOAuthCode, + resolveGiteaBaseUrl, +} from '@roomote/gitea'; +import { authorize, getCallbackHost } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const webEnv = await bootstrapWebRuntimeEnv(); + const callbackOrigin = new URL(getCallbackHost(request)).origin; + const redirect = new URL('/setup', callbackOrigin); + redirect.searchParams.set('step', 'source-control-connect'); + const response = () => NextResponse.redirect(redirect); + const authResult = await authorize(); + 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(callbackOrigin), + }); + redirect.searchParams.set('gitea', 'connected'); + redirect.searchParams.set('sync', '1'); + } 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/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 000000000..5d6fac49b --- /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 000000000..1f096e30c --- /dev/null +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts @@ -0,0 +1,66 @@ +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'); + redirect.searchParams.set('sync', '1'); + } 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/app/globals.css b/apps/web/src/app/globals.css index c29e40faf..3627258b3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -146,6 +146,19 @@ } } + --animate-exit-up: exit-up 0.75s ease-out 1; + + @keyframes exit-up { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(20px); + } + } + --animate-enter-message: enter-message 0.1s ease-out 1; @keyframes enter-message { diff --git a/apps/web/src/components/settings/SourceControl.test.tsx b/apps/web/src/components/settings/SourceControl.test.tsx index 90c9ec701..1d53ad2f2 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 access 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/components/settings/SourceControl.tsx b/apps/web/src/components/settings/SourceControl.tsx index 76686e4d9..a94dc6cec 100644 --- a/apps/web/src/components/settings/SourceControl.tsx +++ b/apps/web/src/components/settings/SourceControl.tsx @@ -889,15 +889,19 @@ function ProviderSetupInstructions({

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

diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index eab1d5381..d988b066f 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -46,7 +46,6 @@ export { Copy, CopyIcon, CornerDownLeftIcon, - CornerDownRight, Cpu, CreditCardIcon, Database, diff --git a/apps/web/src/lib/server/__tests__/get-callback-host.test.ts b/apps/web/src/lib/server/__tests__/get-callback-host.test.ts index 185c17004..b9497bb45 100644 --- a/apps/web/src/lib/server/__tests__/get-callback-host.test.ts +++ b/apps/web/src/lib/server/__tests__/get-callback-host.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; const mockEnvState = vi.hoisted(() => ({ R_APP_URL: 'https://roomote.203-0-113-7.sslip.io', + R_PUBLIC_URL: undefined as string | undefined, })); vi.mock('../env', () => ({ @@ -41,4 +42,15 @@ describe('getCallbackHost', () => { 'https://roomote.example.com/api/slack/callback?code=abc', ); }); + + it('prefers the configured public URL for internal requests', () => { + mockEnvState.R_PUBLIC_URL = 'https://roomote.example.com'; + const request = new NextRequest( + 'http://localhost:13000/api/gitea/callback', + ); + + expect(getCallbackHost(request)).toBe( + 'https://roomote.example.com/api/gitea/callback', + ); + }); }); diff --git a/apps/web/src/lib/server/get-callback-host.ts b/apps/web/src/lib/server/get-callback-host.ts index 3c47bd9bc..27ccbb653 100644 --- a/apps/web/src/lib/server/get-callback-host.ts +++ b/apps/web/src/lib/server/get-callback-host.ts @@ -20,9 +20,11 @@ export function getCallbackHost(request: NextRequest) { // Rewrite any internal origin (any scheme/port) to the configured public // URL so OAuth redirects land back on the host the user's browser can // actually reach (e.g. the ngrok or self-host public domain) instead of - // https://localhost:13000 or http://0.0.0.0:3000. + // https://localhost:13000 or http://0.0.0.0:3000. Prefer R_PUBLIC_URL when + // R_APP_URL is intentionally left at its local development default. if (INTERNAL_HOSTNAMES.has(url.hostname)) { - return `${Env.R_APP_URL}${url.pathname}${url.search}`; + const publicUrl = Env.R_PUBLIC_URL ?? Env.R_APP_URL; + return `${publicUrl}${url.pathname}${url.search}`; } return request.url; diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index ca40c8756..8a8e6e946 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -14,7 +14,7 @@ const { mockAcquireComputeProvisioningLock, mockResolveSavedWorkerImage, mockResolveGiteaBaseUrl, - mockValidateGiteaToken, + mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ mockTxSelect: vi.fn(), mockDbTransaction: vi.fn(), @@ -32,7 +32,7 @@ const { mockResolveGiteaBaseUrl: vi .fn() .mockResolvedValue('https://gitea.example.com'), - mockValidateGiteaToken: vi.fn().mockResolvedValue({ status: 'valid' }), + mockResolveDeploymentEnvVar: vi.fn().mockResolvedValue(null), })); vi.mock('../compute/compute-provisioning', async (importOriginal) => { @@ -51,8 +51,9 @@ vi.mock('@roomote/github', () => ({ })); vi.mock('@roomote/gitea', () => ({ + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, resolveGiteaBaseUrl: mockResolveGiteaBaseUrl, - validateGiteaToken: mockValidateGiteaToken, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -98,6 +99,7 @@ vi.mock('@roomote/db/server', () => ({ isNull: vi.fn(), markTaskStartParallelCountEndedAt: vi.fn(), resolveSavedWorkerImage: mockResolveSavedWorkerImage, + resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, purgeSavedDeploymentWorkerImage: vi.fn(async () => undefined), resolveTelegramRuntimeCredentials: vi.fn(async () => ({ botToken: null, @@ -489,7 +491,7 @@ describe('setup-new source-control config commands', () => { beforeEach(() => { vi.clearAllMocks(); mockResolveGiteaBaseUrl.mockResolvedValue('https://gitea.example.com'); - mockValidateGiteaToken.mockResolvedValue({ status: 'valid' }); + mockResolveDeploymentEnvVar.mockResolvedValue(null); mockDbTransaction.mockImplementation(async (callback) => { const tx = { @@ -532,24 +534,25 @@ describe('setup-new source-control config commands', () => { { provider: 'gitea', values: { - GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_BASE_URL: 'gitea.example.com', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }, ); expect(result.setupNewState.sourceControlProvider).toBe('gitea'); - expect(mockValidateGiteaToken).toHaveBeenCalledWith({ - token: 'gitea-token', - baseUrl: 'https://gitea.example.com', - }); expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ userId: 'setup-test-user', values: expect.arrayContaining([ - expect.objectContaining({ name: 'GITEA_BASE_URL' }), - expect.objectContaining({ name: 'GITEA_TOKEN' }), + expect.objectContaining({ + name: 'GITEA_BASE_URL', + value: 'https://gitea.example.com', + }), + expect.objectContaining({ name: 'GITEA_CLIENT_ID' }), + expect.objectContaining({ name: 'GITEA_CLIENT_SECRET' }), ]), }), ); @@ -560,8 +563,16 @@ describe('setup-new source-control config commands', () => { mockTxSelect .mockReturnValueOnce(createSelectChain([{ setupNewState: {} }])) .mockReturnValueOnce( - createFromOnlySelectChain([{ name: 'GITEA_TOKEN' }]), + createFromOnlySelectChain([ + { name: 'GITEA_CLIENT_ID' }, + { name: 'GITEA_CLIENT_SECRET' }, + ]), ); + mockResolveDeploymentEnvVar.mockImplementation(async (name: string) => + name === 'GITEA_CLIENT_ID' || name === 'GITEA_CLIENT_SECRET' + ? 'saved-value' + : null, + ); await saveSetupNewSourceControlConfigCommand(buildMockAuth(), { provider: 'gitea', @@ -587,30 +598,11 @@ describe('setup-new source-control config commands', () => { }, }), ).rejects.toThrow( - 'Enter the required Gitea configuration values to continue.', + 'Configure the Gitea OAuth client ID and secret to continue.', ); expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - - it('rejects invalid Gitea tokens before saving config', async () => { - mockValidateGiteaToken.mockResolvedValue({ - status: 'invalid', - error: 'Gitea rejected the token.', - }); - - await expect( - saveSetupNewSourceControlConfigCommand(buildMockAuth(), { - provider: 'gitea', - values: { - GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', - }, - }), - ).rejects.toThrow('Gitea rejected the token.'); - - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); - }); }); describe('setup-new compute config commands', () => { diff --git a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts index ced30d0d6..c2228cc77 100644 --- a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts +++ b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts @@ -34,6 +34,8 @@ vi.mock('@roomote/github', () => ({ })); vi.mock('@roomote/gitea', () => ({ + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, resolveGiteaBaseUrl: vi.fn(async () => 'https://gitea.example.com'), validateGiteaToken: vi.fn(async () => ({ status: 'valid' })), })); diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 19565e6c6..c58108e63 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -3,12 +3,14 @@ import type { UserAuthSuccess } from '@/types'; const { mockEnsureAdoServiceHooksForRepositories, mockEnsureGiteaWebhooksForRepositories, + mockEnsureGitLabWebhooksForProjects, mockRemoveAdoServiceHooksForRepositories, mockRemoveGiteaWebhooksForRepositories, mockRemoveGitLabWebhooksForProjects, mockEnvironmentMappingRows, mockResolveDeploymentEnvVar, mockSyncAdoRepositories, + mockSyncGitLabRepositories, mockSyncGiteaRepositories, mockUpsertDeploymentEnvironmentVariables, mockResolveAdoOrganization, @@ -18,12 +20,14 @@ const { } = vi.hoisted(() => ({ mockEnsureAdoServiceHooksForRepositories: vi.fn(), mockEnsureGiteaWebhooksForRepositories: vi.fn(), + mockEnsureGitLabWebhooksForProjects: vi.fn(), mockRemoveAdoServiceHooksForRepositories: vi.fn(), mockRemoveGiteaWebhooksForRepositories: vi.fn(), mockRemoveGitLabWebhooksForProjects: vi.fn(), mockEnvironmentMappingRows: { rows: [] as { repositoryId: string }[] }, mockResolveDeploymentEnvVar: vi.fn(), mockSyncAdoRepositories: vi.fn(), + mockSyncGitLabRepositories: vi.fn(), mockSyncGiteaRepositories: vi.fn(), mockUpsertDeploymentEnvironmentVariables: vi.fn(), mockResolveAdoOrganization: vi.fn(), @@ -47,6 +51,8 @@ vi.mock('@roomote/ado', () => ({ vi.mock('@roomote/gitea', () => ({ ensureGiteaWebhooksForRepositories: mockEnsureGiteaWebhooksForRepositories, + normalizeGiteaBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, removeGiteaWebhooksForRepositories: mockRemoveGiteaWebhooksForRepositories, resolveGiteaBaseUrl: vi.fn().mockResolvedValue('https://gitea.example.com'), syncGiteaRepositories: mockSyncGiteaRepositories, @@ -54,10 +60,14 @@ vi.mock('@roomote/gitea', () => ({ })); vi.mock('@roomote/gitlab', () => ({ - ensureGitLabWebhooksForProjects: vi.fn(), + buildGitLabApiBaseUrl: (value: string) => + `${value.replace(/\/+$/, '')}/api/v4`, + ensureGitLabWebhooksForProjects: mockEnsureGitLabWebhooksForProjects, + normalizeGitLabBaseUrl: (value: string) => + value.startsWith('http') ? value : `https://${value}`, removeGitLabWebhooksForProjects: mockRemoveGitLabWebhooksForProjects, - syncGitLabRepositories: vi.fn(), - validateGitLabToken: vi.fn().mockResolvedValue({ status: 'valid' }), + resolveGitLabBaseUrl: vi.fn().mockResolvedValue('https://gitlab.com'), + syncGitLabRepositories: mockSyncGitLabRepositories, })); vi.mock('@roomote/db/server', () => ({ @@ -144,6 +154,9 @@ describe('source-control commands', () => { mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([]); mockRemoveAdoServiceHooksForRepositories.mockResolvedValue([]); mockRemoveGitLabWebhooksForProjects.mockResolvedValue([]); + mockEnsureGitLabWebhooksForProjects.mockResolvedValue([ + { status: 'created', repositoryFullName: 'acme/gitlab-app' }, + ]); mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, { status: 'updated', repositoryFullName: 'Roomote/gitea-api' }, @@ -161,6 +174,48 @@ describe('source-control commands', () => { mockValidateAdoToken.mockResolvedValue({ status: 'valid' }); }); + it('creates GitLab webhooks during the OAuth-triggered repository sync', async () => { + mockSyncGitLabRepositories.mockResolvedValue({ + success: true, + repositories: [ + { + id: 'gitlab-repo-row-1', + externalRepoId: '42', + fullName: 'acme/gitlab-app', + }, + ], + }); + + const result = await syncRepositoriesCommand(buildMockAuth(), { + provider: 'gitlab', + }); + + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: [ + expect.objectContaining({ + name: 'GITLAB_WEBHOOK_SECRET', + value: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ], + }), + ); + expect(mockEnsureGitLabWebhooksForProjects).toHaveBeenCalledWith({ + projects: [{ projectId: '42', repositoryFullName: 'acme/gitlab-app' }], + webhookUrl: 'https://roomote.example.com/api/webhooks/gitlab', + secretToken: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(result).toMatchObject({ + success: true, + webhooks: { + status: 'configured', + created: 1, + skippedUnmapped: 0, + }, + }); + }); + it('syncs Gitea repositories and configures pull request webhooks', async () => { const result = await syncRepositoriesCommand(buildMockAuth(), { provider: 'gitea', @@ -200,7 +255,7 @@ describe('source-control commands', () => { }); }); - it('only hooks environment-mapped repositories and removes hooks from unmapped ones', async () => { + it('hooks all Gitea repositories returned by OAuth', async () => { mockSyncGiteaRepositories.mockResolvedValue({ success: true, repositories: [ @@ -210,9 +265,7 @@ describe('source-control commands', () => { }); mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, - ]); - mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([ - { status: 'removed', repositoryFullName: 'acme/private-work-repo' }, + { status: 'created', repositoryFullName: 'acme/private-work-repo' }, ]); const result = await syncRepositoriesCommand(buildMockAuth(), { @@ -221,45 +274,49 @@ describe('source-control commands', () => { expect(mockEnsureGiteaWebhooksForRepositories).toHaveBeenCalledWith( expect.objectContaining({ - repositories: [{ repositoryFullName: 'Roomote/gitea-app' }], + repositories: [ + { repositoryFullName: 'Roomote/gitea-app' }, + { repositoryFullName: 'acme/private-work-repo' }, + ], }), ); - expect(mockRemoveGiteaWebhooksForRepositories).toHaveBeenCalledWith({ - repositories: [{ repositoryFullName: 'acme/private-work-repo' }], - webhookUrl: 'https://roomote.example.com/api/webhooks/gitea', - }); + expect(mockRemoveGiteaWebhooksForRepositories).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, webhooks: { status: 'configured', - created: 1, + created: 2, updated: 0, failed: [], - skippedUnmapped: 1, - removed: 1, + skippedUnmapped: 0, + removed: 0, }, }); }); - it('does not create webhooks when no synced repository is environment-mapped', async () => { + it('creates webhooks when no synced repository is environment-mapped', async () => { mockEnvironmentMappingRows.rows = []; - mockRemoveGiteaWebhooksForRepositories.mockResolvedValue([ - { status: 'not_found', repositoryFullName: 'Roomote/gitea-app' }, + mockEnsureGiteaWebhooksForRepositories.mockResolvedValue([ + { status: 'created', repositoryFullName: 'Roomote/gitea-app' }, ]); const result = await syncRepositoriesCommand(buildMockAuth(), { provider: 'gitea', }); - expect(mockEnsureGiteaWebhooksForRepositories).not.toHaveBeenCalled(); + expect(mockEnsureGiteaWebhooksForRepositories).toHaveBeenCalledWith( + expect.objectContaining({ + repositories: [{ repositoryFullName: 'Roomote/gitea-app' }], + }), + ); expect(result).toMatchObject({ success: true, webhooks: { status: 'configured', - created: 0, + created: 1, updated: 0, failed: [], - skippedUnmapped: 1, + skippedUnmapped: 0, removed: 0, }, }); @@ -353,20 +410,16 @@ describe('source-control commands', () => { expect(mockValidateAdoToken).not.toHaveBeenCalled(); }); - it('rejects invalid Gitea tokens during config validation', async () => { - mockValidateGiteaToken.mockResolvedValue({ - status: 'invalid', - error: 'Gitea rejected the token.', - }); - + it('requires Gitea OAuth credentials during config validation', async () => { await expect( assertValidSourceControlConfigInput({ provider: 'gitea', values: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', }, }), - ).rejects.toThrow('Gitea rejected the token.'); + ).rejects.toThrow( + 'Configure the Gitea OAuth client ID and secret to continue.', + ); }); }); diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 7b4978a87..534c2e881 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -79,12 +79,11 @@ export async function getSourceControlConfigStatusCommand( } /** - * Webhooks are only auto-created for synced repositories that are mapped to - * at least one environment. Hooking every project the deployment token can - * administer leaks events from unrelated repositories (for example private - * work projects visible to a personal PAT) to the Roomote URL, so sync - * treats environment mappings as the operator's selection and removes the - * Roomote webhook from synced-but-unmapped repositories. + * Most provider webhooks are only auto-created for synced repositories that + * are mapped to at least one environment. GitLab and Gitea are exceptions + * because their OAuth callbacks immediately trigger the first repository + * sync, before onboarding creates environment mappings; their provider + * wrappers opt into registering hooks for the OAuth-visible projects. */ async function getEnvironmentMappedRepositoryIds(): Promise> { const mappingRows = await db @@ -153,8 +152,12 @@ async function configureScopedProviderWebhooks< actorUserId: string; logPrefix: string; removalDescription: string; + scopeToEnvironmentMappings?: boolean; }): Promise> { - const mappedRepositoryIds = await getEnvironmentMappedRepositoryIds(); + const scopeToEnvironmentMappings = params.scopeToEnvironmentMappings ?? true; + const mappedRepositoryIds = scopeToEnvironmentMappings + ? await getEnvironmentMappedRepositoryIds() + : new Set(params.repositories.map((repository) => repository.id)); const mappedTargets = params.repositories .filter((repository) => mappedRepositoryIds.has(repository.id)) .flatMap(params.toTarget); @@ -291,6 +294,10 @@ async function configureGitLabWebhooks( actorUserId, logPrefix: '[configureGitLabWebhooks]', removalDescription: 'remove webhooks from unmapped projects', + // GitLab OAuth is followed immediately by the repository sync. At that + // point onboarding has not created environment mappings yet, so register + // hooks for the projects returned by OAuth instead of skipping them. + scopeToEnvironmentMappings: false, }); } @@ -346,6 +353,10 @@ async function configureGiteaWebhooks( actorUserId, logPrefix: '[configureGiteaWebhooks]', removalDescription: 'remove webhooks from unmapped repositories', + // Gitea OAuth is followed immediately by the repository sync, before + // onboarding creates environment mappings. Register hooks for the + // repositories returned by OAuth instead of skipping them. + scopeToEnvironmentMappings: false, }); } @@ -641,7 +652,12 @@ export async function saveSourceControlConfigValues(params: { return [ { name: field.envVarName, - value: nextValue, + value: + field.envVarName === 'GITEA_BASE_URL' + ? Gitea.normalizeGiteaBaseUrl(nextValue) + : field.envVarName === 'GITLAB_BASE_URL' + ? GitLab.normalizeGitLabBaseUrl(nextValue) + : nextValue, }, ]; }); @@ -710,13 +726,25 @@ export async function assertValidSourceControlConfigInput(params: { values?: Partial>; allowIncompleteDelegated?: boolean; }): Promise { - const nextGitLabToken = + const nextGitLabClientId = params.provider === 'gitlab' - ? params.values?.['GITLAB_TOKEN']?.trim() + ? (params.values?.['GITLAB_CLIENT_ID']?.trim() ?? + (await resolveDeploymentEnvVar('GITLAB_CLIENT_ID'))) : undefined; - const nextGiteaToken = + const nextGitLabClientSecret = + params.provider === 'gitlab' + ? (params.values?.['GITLAB_CLIENT_SECRET']?.trim() ?? + (await resolveDeploymentEnvVar('GITLAB_CLIENT_SECRET'))) + : undefined; + 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' @@ -771,33 +799,20 @@ export async function assertValidSourceControlConfigInput(params: { ); } - if (nextGitLabToken) { - const validation = await GitLab.validateGitLabToken({ - token: nextGitLabToken, - }); - - if (validation.status === 'invalid') { - throw new Error(validation.error); - } + if ( + params.provider === 'gitlab' && + !(nextGitLabClientId && nextGitLabClientSecret) + ) { + throw new Error('Configure the GitLab OAuth client ID and secret.'); } - if (nextGiteaToken) { - const nextGiteaBaseUrl = - params.values?.['GITEA_BASE_URL']?.trim() ?? - (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 48af02a64..20a8d6309 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 eb75199b3..9f029921e 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -62,6 +62,7 @@ vi.mock('@roomote/db/encryption', () => ({ import { buildGiteaApiBaseUrl, + normalizeGiteaBaseUrl, buildGiteaRepositoryValues, createTaskRunGiteaCredentials, createGiteaPullRequestComment, @@ -69,7 +70,6 @@ import { removeGiteaWebhooksForRepositories, getGiteaAuthenticatedUser, listGiteaRepositories, - validateGiteaToken, type GiteaRepository, } from '../api'; @@ -88,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([ @@ -107,29 +103,33 @@ 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', () => { expect(buildGiteaApiBaseUrl('https://git.example.com/')).toBe( 'https://git.example.com/api/v1', ); + expect(buildGiteaApiBaseUrl('https://git.example.com/api/v1')).toBe( + 'https://git.example.com/api/v1', + ); + expect(buildGiteaApiBaseUrl('https://git.example.com/gitea/api/v1/')).toBe( + 'https://git.example.com/gitea/api/v1', + ); + expect(buildGiteaApiBaseUrl('https://gitea.com/roocode/')).toBe( + 'https://gitea.com/api/v1', + ); + expect(normalizeGiteaBaseUrl('gitea.com')).toBe('https://gitea.com'); + expect(normalizeGiteaBaseUrl('git.example.com/')).toBe( + 'https://git.example.com', + ); + expect(normalizeGiteaBaseUrl('git.example.com////////')).toBe( + 'https://git.example.com', + ); }); it('lists authenticated Gitea repositories with token auth and pagination', async () => { @@ -189,7 +189,7 @@ describe('Gitea API helpers', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - Authorization: 'token gitea_test', + Authorization: 'Bearer gitea_test', }), }), ); @@ -201,7 +201,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({ @@ -268,49 +270,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() @@ -436,7 +401,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"'), @@ -478,7 +443,7 @@ describe('Gitea API helpers', () => { description: 'Work on Gitea', sourceControlProvider: 'gitea', }), - { username: 'roomote-bot' }, + { username: 'roomote-bot', token: 'gitea_oauth_token' }, ); expect(result).toEqual({ @@ -487,7 +452,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 000000000..5b53aae4b --- /dev/null +++ b/packages/gitea/src/__tests__/oauth.test.ts @@ -0,0 +1,52 @@ +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('/gitea/login/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('client-id'); + expect(url.searchParams.get('state')).toBe('deployment-state'); + expect(url.searchParams.get('scope')).toBe(getGiteaOAuthScopes().join(' ')); + }); + + it('preserves a self-managed Gitea base path in the authorization URL', () => { + const result = createGiteaOAuthAuthorizationUrl({ + baseUrl: 'https://git.example/gitea', + clientId: 'client-id', + redirectUri: 'https://roomote.example/callback', + state: 'state', + }); + + expect(new URL(result.url).toString()).toContain( + 'https://git.example/gitea/login/oauth/authorize?', + ); + }); +}); diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index ed740147f..4cb021e44 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', @@ -100,18 +100,39 @@ export type GiteaWebhookEnsureResult = { error?: string; }; -function normalizeBaseUrl(baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); +function removeTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return value.slice(0, end); +} + +export function normalizeGiteaBaseUrl(baseUrl: string): string { + const trimmed = removeTrailingSlashes(baseUrl.trim()); if (!trimmed) { throw new Error('GITEA_BASE_URL cannot be empty.'); } - return new URL(trimmed).toString().replace(/\/+$/, ''); + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + const apiPathSuffix = '/api/v1'; + + if (url.hostname === 'gitea.com') { + url.pathname = '/'; + } else if (url.pathname.endsWith(apiPathSuffix)) { + url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; + } + + return removeTrailingSlashes(url.toString()); } export async function resolveGiteaToken(): Promise { - return resolveDeploymentEnvVar('GITEA_TOKEN'); + return resolveGiteaOAuthAccessToken(); } let cachedGiteaDeploymentUser: { @@ -122,15 +143,15 @@ let cachedGiteaDeploymentUser: { export async function resolveGiteaBaseUrl(): Promise { const baseUrl = await resolveDeploymentEnvVar('GITEA_BASE_URL'); - return baseUrl ? normalizeBaseUrl(baseUrl) : null; + return baseUrl ? normalizeGiteaBaseUrl(baseUrl) : null; } export async function resolveGiteaUsername(): Promise { - return resolveDeploymentEnvVar('GITEA_USERNAME'); + return (await getGiteaOAuthConnection())?.username || null; } export function buildGiteaApiBaseUrl(baseUrl: string): string { - return new URL('api/v1', `${normalizeBaseUrl(baseUrl)}/`).toString(); + return new URL('api/v1', `${normalizeGiteaBaseUrl(baseUrl)}/`).toString(); } function buildGiteaApiUrl( @@ -173,7 +194,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) } : {}), @@ -215,54 +236,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, @@ -273,7 +246,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()); @@ -323,11 +296,11 @@ export async function listGiteaRepositories({ } function hostFromBaseUrl(baseUrl: string): string { - return new URL(normalizeBaseUrl(baseUrl)).host; + return new URL(normalizeGiteaBaseUrl(baseUrl)).host; } function buildGiteaWebUrl(baseUrl: string, path: string): string { - return new URL(path.replace(/^\//, ''), `${normalizeBaseUrl(baseUrl)}/`) + return new URL(path.replace(/^\//, ''), `${normalizeGiteaBaseUrl(baseUrl)}/`) .toString() .replace(/\/+$/, ''); } @@ -573,7 +546,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()); @@ -677,7 +652,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.', ); } @@ -849,7 +824,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()); @@ -946,7 +923,7 @@ async function removeGiteaRepositoryWebhook({ method: 'DELETE', headers: { Accept: 'application/json', - Authorization: `token ${token}`, + Authorization: `Bearer ${token}`, }, }, ); @@ -983,7 +960,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()); @@ -1048,7 +1027,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 b1c13e734..18ac6e093 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 000000000..028ce3374 --- /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 3ac5c335e..2ab8dba1b 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, @@ -73,7 +80,7 @@ import { revokeGitLabScopedProjectToken, type GitLabProject, listGitLabProjects, - validateGitLabToken, + normalizeGitLabBaseUrl, } from '../api'; function makeTaskRun(payload: TaskRun['payload']): TaskRun { @@ -114,6 +121,20 @@ describe('resolveGitLabBaseUrl', () => { 'https://gitlab.example.com', ); }); + + it('accepts scheme-less URLs and removes API or hosted-account paths', async () => { + process.env.GITLAB_BASE_URL = 'gitlab.example.com/api/v4/'; + + await expect(resolveGitLabBaseUrl()).resolves.toBe( + 'https://gitlab.example.com', + ); + expect(normalizeGitLabBaseUrl('gitlab.com/roomote/')).toBe( + 'https://gitlab.com', + ); + expect(normalizeGitLabBaseUrl('gitlab.example.com////////')).toBe( + 'https://gitlab.example.com', + ); + }); }); describe('buildGitLabApiBaseUrl', () => { @@ -198,7 +219,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.', ); }); @@ -278,12 +299,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); @@ -296,12 +316,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 { @@ -334,6 +348,7 @@ describe('createTaskRunScopedGitLabTokens', () => { credentials: [ { host: 'gitlab.com', + originBaseUrl: 'https://gitlab.com', repositoryFullName: 'group/project', username: 'oauth2', token: 'glptt_repo_scoped', @@ -355,7 +370,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"'), @@ -634,9 +649,10 @@ describe('createTaskRunScopedGitLabTokens', () => { proxyCredentials: [ { host: 'gitlab.com', + originBaseUrl: 'https://gitlab.com', repositoryFullName: 'group/project', username: 'oauth2', - token: 'glpat_deployment_token', + token: 'oauth_access_token', }, ], artifactsPatch: { @@ -738,21 +754,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 () => { @@ -773,14 +782,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; + it('returns null when no GitLab OAuth connection is configured', async () => { + mockGitLabOAuthAccessToken.mockResolvedValue(null); const fetchMock = vi.fn(); await expect( @@ -830,58 +839,8 @@ describe('createGitLabMergeRequestNote', () => { fetchImpl: vi.fn(), }), ).rejects.toThrow( - 'GITLAB_TOKEN is required to create GitLab merge request notes.', - ); - }); -}); - -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, - }), + 'GitLab OAuth authorization is required to create merge request notes.', ); - - 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'); }); }); diff --git a/packages/gitlab/src/__tests__/oauth.test.ts b/packages/gitlab/src/__tests__/oauth.test.ts new file mode 100644 index 000000000..55c13a43a --- /dev/null +++ b/packages/gitlab/src/__tests__/oauth.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { createGitLabOAuthAuthorizationUrl } from '../oauth'; + +describe('GitLab deployment OAuth', () => { + it('preserves a self-managed GitLab base path in the authorization URL', () => { + const result = createGitLabOAuthAuthorizationUrl({ + baseUrl: 'https://git.example/gitlab', + clientId: 'client-id', + redirectUri: 'https://roomote.example/callback', + state: 'state', + }); + + expect(new URL(result.url).toString()).toContain( + 'https://git.example/gitlab/oauth/authorize?', + ); + }); +}); diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 8f60d450d..4c6b93dcd 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'; @@ -76,6 +80,7 @@ export type GitLabScopedProjectTokenDescriptor = z.infer< >; export type GitLabScopedProjectTokenCredential = { host: string; + originBaseUrl: string; repositoryFullName: string; username: string; token: string; @@ -108,18 +113,39 @@ export type ListGitLabProjectsOptions = { stopAfter?: number; }; -function normalizeBaseUrl(baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); +function removeTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return value.slice(0, end); +} + +export function normalizeGitLabBaseUrl(baseUrl: string): string { + const trimmed = removeTrailingSlashes(baseUrl.trim()); if (!trimmed) { throw new Error('GITLAB_BASE_URL cannot be empty.'); } - return new URL(trimmed).toString().replace(/\/+$/, ''); + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + const apiPathSuffix = '/api/v4'; + + if (url.hostname === 'gitlab.com') { + url.pathname = '/'; + } else if (url.pathname.endsWith(apiPathSuffix)) { + url.pathname = url.pathname.slice(0, -apiPathSuffix.length) || '/'; + } + + return removeTrailingSlashes(url.toString()); } export async function resolveGitLabToken(): Promise { - return resolveDeploymentEnvVar('GITLAB_TOKEN'); + return resolveGitLabOAuthAccessToken(); } let cachedGitLabDeploymentUser: { @@ -128,7 +154,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. */ @@ -183,7 +210,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.', ); } @@ -203,19 +230,19 @@ export async function createGitLabMergeRequestNote({ export async function resolveGitLabBaseUrl(): Promise { const baseUrl = await resolveDeploymentEnvVar('GITLAB_BASE_URL'); - return normalizeBaseUrl(baseUrl ?? DEFAULT_GITLAB_BASE_URL); + return normalizeGitLabBaseUrl(baseUrl ?? DEFAULT_GITLAB_BASE_URL); } export function buildGitLabApiBaseUrl(baseUrl: string): string { - return new URL('api/v4', `${normalizeBaseUrl(baseUrl)}/`).toString(); + return new URL('api/v4', `${normalizeGitLabBaseUrl(baseUrl)}/`).toString(); } function hostFromBaseUrl(baseUrl: string): string { - return new URL(normalizeBaseUrl(baseUrl)).host; + return new URL(normalizeGitLabBaseUrl(baseUrl)).host; } function buildGitLabWebUrl(baseUrl: string, path: string): string { - return new URL(path.replace(/^\//, ''), `${normalizeBaseUrl(baseUrl)}/`) + return new URL(path.replace(/^\//, ''), `${normalizeGitLabBaseUrl(baseUrl)}/`) .toString() .replace(/\/+$/, ''); } @@ -290,7 +317,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) } : {}), @@ -352,7 +381,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 = @@ -402,63 +433,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, @@ -595,7 +569,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[] = []; @@ -668,7 +644,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[] = []; @@ -981,6 +959,7 @@ async function createScopedProjectToken(params: { apiBaseUrl?: string; fetchImpl?: typeof fetch; host: string; + originBaseUrl: string; projectId: string; repositoryFullName: string; runId: number; @@ -1008,6 +987,7 @@ async function createScopedProjectToken(params: { return { credential: { host: params.host, + originBaseUrl: params.originBaseUrl, repositoryFullName: params.repositoryFullName, username: data.username?.trim() || 'oauth2', token: data.token, @@ -1024,6 +1004,7 @@ async function rotateScopedProjectToken(params: { apiBaseUrl?: string; fetchImpl?: typeof fetch; host: string; + originBaseUrl: string; descriptor: GitLabScopedProjectTokenDescriptor; token: string; }): Promise<{ @@ -1046,6 +1027,7 @@ async function rotateScopedProjectToken(params: { return { credential: { host: params.host, + originBaseUrl: params.originBaseUrl, repositoryFullName: params.descriptor.repositoryFullName, username: data.username?.trim() || 'oauth2', token: data.token, @@ -1128,13 +1110,36 @@ 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()); 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, + originBaseUrl: baseUrl, + 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, @@ -1167,6 +1172,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, descriptor: existingDescriptor, token: deploymentToken, }).catch( @@ -1175,6 +1181,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, projectId: repository.projectId, repositoryFullName: repository.repositoryFullName, runId: taskRun.id, @@ -1185,6 +1192,7 @@ export async function createTaskRunScopedGitLabTokens( apiBaseUrl, fetchImpl: options?.fetchImpl, host, + originBaseUrl: baseUrl, projectId: repository.projectId, repositoryFullName: repository.repositoryFullName, runId: taskRun.id, @@ -1213,6 +1221,7 @@ export async function createTaskRunScopedGitLabTokens( credentials: [], proxyCredentials: repositoriesList.map((repository) => ({ host, + originBaseUrl: baseUrl, repositoryFullName: repository.repositoryFullName, username: 'oauth2', token: deploymentToken, diff --git a/packages/gitlab/src/index.ts b/packages/gitlab/src/index.ts index b1c13e734..18ac6e093 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 000000000..1a5ee1cf9 --- /dev/null +++ b/packages/gitlab/src/oauth.ts @@ -0,0 +1,249 @@ +import { randomBytes } from 'node:crypto'; + +import { db, deploymentSecrets, sql } from '@roomote/db/server'; +import { decryptSecrets, encryptJSON } from '@roomote/db/encryption'; + +const SECRET_NAME = 'gitlab_deployment_oauth_connection'; +// 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'; + +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 { + // 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( + 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/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts index 7afaefec5..25536d322 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts new file mode 100644 index 000000000..0a75ad389 --- /dev/null +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@roomote/gitlab', () => ({ + isGitLabOAuthAccessToken: (token: string) => token === 'oauth-token', +})); + +import { buildGitLabTokenHeader } from '../source-control-pull-request-shared'; + +describe('buildGitLabTokenHeader', () => { + it('uses the Bearer authorization header for OAuth tokens', () => { + expect(buildGitLabTokenHeader('oauth-token')).toEqual({ + name: 'Authorization', + value: 'Bearer oauth-token', + }); + }); + + it('uses the private-token header for non-OAuth tokens', () => { + expect(buildGitLabTokenHeader('glpat-token')).toEqual({ + name: 'PRIVATE-TOKEN', + value: 'glpat-token', + }); + }); +}); diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts index e48b5b94e..ccacaf45d 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index f3b357cb1..278de6432 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -45,6 +45,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/gitlab', () => ({ resolveGitLabToken: (...args: unknown[]) => mockResolveGitLabToken(...args), + isGitLabOAuthAccessToken: () => false, resolveGitLabBaseUrl: async () => 'https://gitlab.com', buildGitLabApiBaseUrl: (baseUrl: string) => `${baseUrl.replace(/\/+$/, '')}/api/v4`, diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts index b44f1792d..9f94da444 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts @@ -34,6 +34,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, isDraftTitle, @@ -1036,7 +1037,7 @@ async function getGitLabMergeRequestDetails({ `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabMergeRequestDetailsSchema, }); @@ -1106,7 +1107,7 @@ async function listGitLabMergeRequestComments({ `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}/discussions`, { per_page: 100 }, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabDiscussionListSchema, }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts index 4ed9f9046..e7dedd6a1 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts @@ -12,9 +12,19 @@ import { getSourceControlProviderLabel, type SourceControlProvider, } from '@roomote/types'; +import { isGitLabOAuthAccessToken } from '@roomote/gitlab'; export type FetchImpl = typeof fetch; +export function buildGitLabTokenHeader(token: string): { + name: string; + value: string; +} { + return isGitLabOAuthAccessToken(token) + ? { name: 'Authorization', value: `Bearer ${token}` } + : { name: 'PRIVATE-TOKEN', value: token }; +} + export type RepositoryRow = { id: string; sourceControlProvider: SourceControlProvider; diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts index d5164624b..205c5e77e 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts @@ -33,6 +33,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, parseAdoRepositoryFullName, @@ -591,7 +592,7 @@ async function writeGitLabMergeRequest({ }): Promise { const { projectId, token, apiBaseUrl } = await resolveGitLabWriteContext(repository); - const tokenHeader = { name: 'PRIVATE-TOKEN', value: token }; + const tokenHeader = buildGitLabTokenHeader(token); const mergeRequestPath = `/projects/${encodeURIComponent(projectId)}/merge_requests/${input.prNumber}`; switch (input.action) { diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index 706ac8ea0..5262637b6 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -47,6 +47,7 @@ import { assertRepositoryInTaskRunScope, buildAdoBasicAuthHeader, buildApiUrl, + buildGitLabTokenHeader, formatResponseBody, getPayloadRecord, isDraftTitle, @@ -588,7 +589,7 @@ async function createOrUpdateGitLabMergeRequest({ )}/merge_requests/${existing.iid}`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), body: { ...common, ...(input.labels.length > 0 @@ -607,7 +608,7 @@ async function createOrUpdateGitLabMergeRequest({ )}/merge_requests`, {}, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), body: { source_branch: input.sourceBranch, target_branch: targetBranch, @@ -666,7 +667,7 @@ async function listGitLabMergeRequests({ per_page: 2, }, ), - tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + tokenHeader: buildGitLabTokenHeader(token), schema: gitLabMergeRequestListSchema, }); } diff --git a/packages/types/src/setup-source-control-config.test.ts b/packages/types/src/setup-source-control-config.test.ts index 8b45a0a9a..473340ec5 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 }, }); @@ -118,7 +150,8 @@ describe('buildSetupSourceControlStatus', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }); @@ -137,7 +170,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 +183,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 +202,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,20 +270,22 @@ 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'); - const optionalGitLabClientId = gitlab?.fields.find( + const requiredGitLabClientId = gitlab?.fields.find( (field) => field.envVarName === 'GITLAB_CLIENT_ID', ); - const optionalGitLabClientSecret = gitlab?.fields.find( + const requiredGitLabClientSecret = gitlab?.fields.find( (field) => field.envVarName === 'GITLAB_CLIENT_SECRET', ); const giteaStatus = buildSetupSourceControlStatus({ runtimeEnv: { GITEA_BASE_URL: 'https://gitea.example.com', - GITEA_TOKEN: 'gitea-token', + GITEA_CLIENT_ID: 'gitea-client-id', + GITEA_CLIENT_SECRET: 'gitea-client-secret', }, }); const gitea = giteaStatus.providers.find((p) => p.provider === 'gitea'); @@ -254,16 +293,16 @@ describe('buildSetupSourceControlStatus', () => { (field) => field.envVarName === 'GITEA_WEBHOOK_SECRET', ); - expect(optionalGitLabClientId).toMatchObject({ - required: false, - runtimeSatisfied: false, + expect(requiredGitLabClientId).toMatchObject({ + runtimeSatisfied: true, savedSatisfied: false, }); - expect(optionalGitLabClientSecret).toMatchObject({ - required: false, - runtimeSatisfied: false, + expect(requiredGitLabClientSecret).toMatchObject({ + runtimeSatisfied: true, savedSatisfied: false, }); + expect(requiredGitLabClientId?.required).not.toBe(false); + expect(requiredGitLabClientSecret?.required).not.toBe(false); expect(optionalGiteaWebhookSecret).toMatchObject({ required: false, runtimeSatisfied: false, @@ -316,18 +355,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 +407,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 +422,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 }, }); @@ -429,4 +474,23 @@ describe('getSetupSourceControlVisibleFields', () => { expect(names).not.toContain('ADO_WEBHOOK_SECRET'); }); + + it.each([ + ['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', + (provider, expected) => { + const descriptor = SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.find( + (candidate) => candidate.provider === provider, + ); + + expect( + getSetupSourceControlVisibleFields(descriptor?.fields ?? []).map( + (field) => field.envVarName, + ), + ).toEqual(expected); + }, + ); }); diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index bfa3369b2..9ccc884cf 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -240,12 +240,6 @@ function buildProviderFields( ]; case 'gitlab': return [ - { - envVarName: 'GITLAB_TOKEN', - acceptedEnvVarNames: ['GITLAB_TOKEN'], - label: 'GitLab Personal Access Token', - secret: true, - }, { envVarName: 'GITLAB_BASE_URL', acceptedEnvVarNames: ['GITLAB_BASE_URL'], @@ -255,15 +249,13 @@ function buildProviderFields( { envVarName: 'GITLAB_CLIENT_ID', acceptedEnvVarNames: ['GITLAB_CLIENT_ID'], - label: 'GitLab OAuth Client ID', - required: false, + label: 'OAuth Application ID', }, { envVarName: 'GITLAB_CLIENT_SECRET', acceptedEnvVarNames: ['GITLAB_CLIENT_SECRET'], - label: 'GitLab OAuth Client Secret', + label: 'OAuth App Secret', secret: true, - required: false, }, { envVarName: 'GITLAB_WEBHOOK_SIGNING_TOKEN', @@ -271,6 +263,7 @@ function buildProviderFields( label: 'GitLab Webhook Signing Token', secret: true, required: false, + setupHidden: true, }, { envVarName: 'GITLAB_WEBHOOK_SECRET', @@ -278,6 +271,7 @@ function buildProviderFields( label: 'GitLab Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; case 'gitea': @@ -287,30 +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, - }, { envVarName: 'GITEA_CLIENT_ID', acceptedEnvVarNames: ['GITEA_CLIENT_ID'], label: 'Gitea OAuth Client ID', - required: false, + required: true, }, { envVarName: 'GITEA_CLIENT_SECRET', acceptedEnvVarNames: ['GITEA_CLIENT_SECRET'], label: 'Gitea OAuth Client Secret', secret: true, - required: false, + required: true, }, { envVarName: 'GITEA_WEBHOOK_SECRET', @@ -318,6 +300,7 @@ function buildProviderFields( label: 'Gitea Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; case 'ado': @@ -410,12 +393,14 @@ function buildProviderFields( acceptedEnvVarNames: ['BITBUCKET_BASE_URL'], label: 'Bitbucket Base URL', required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_CLIENT_ID', acceptedEnvVarNames: ['BITBUCKET_CLIENT_ID'], label: 'Bitbucket OAuth Client ID', required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_CLIENT_SECRET', @@ -423,6 +408,7 @@ function buildProviderFields( label: 'Bitbucket OAuth Client Secret', secret: true, required: false, + setupHidden: true, }, { envVarName: 'BITBUCKET_WEBHOOK_SECRET', @@ -430,6 +416,7 @@ function buildProviderFields( label: 'Bitbucket Webhook Secret', secret: true, required: false, + setupHidden: true, }, ]; } @@ -480,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');