diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index 077bda824..e86901d23 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -367,6 +367,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Publish GitHub Release for product tag + id: publish_release env: GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} VERSION: ${{ needs.prepare.outputs.version }} @@ -394,6 +395,7 @@ jobs: if gh release view "$tag" >/dev/null 2>&1; then echo "GitHub Release $tag already exists; skip." + echo "created=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -410,8 +412,50 @@ jobs: --latest rm -f "$notes_file" + echo "created=true" >> "$GITHUB_OUTPUT" echo "Published GitHub Release $tag (images ready)" + - name: Announce GitHub Release in Discord + if: ${{ steps.publish_release.outputs.created == 'true' }} + continue-on-error: true + env: + DISCORD_MAIN_WEBHOOK_URL: ${{ secrets.DISCORD_MAIN_WEBHOOK_URL }} + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${DISCORD_MAIN_WEBHOOK_URL:-}" ]; then + echo "::notice::DISCORD_MAIN_WEBHOOK_URL is not configured; skipping the Discord release announcement." + exit 0 + fi + + # Custom Discord payloads use the base webhook endpoint. Also accept + # URLs copied from the native GitHub integration setup for convenience. + webhook_url="${DISCORD_MAIN_WEBHOOK_URL%/github}" + payload_file="$(mktemp)" + trap 'rm -f "$payload_file"' EXIT + + gh release view "$VERSION" \ + --json name,body,url,publishedAt,tagName \ + | node scripts/release/build-discord-release-payload.mjs \ + > "$payload_file" + + curl \ + --fail-with-body \ + --silent \ + --show-error \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "@$payload_file" \ + "$webhook_url" + + echo "Announced GitHub Release $VERSION in Discord" + notify-ops: name: Notify ops repository runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0285bd558..f19d2d94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.26.0 (2026-07-29) + +This release expands Linear and GitHub workflows, makes release information easier to find, and improves setup and task reliability. + +### Highlights + +- Set up and manage Linear, link user accounts, and start app-mention, issue-delegation, scheduled, and direct Linear tasks. +- Create or fork a GitHub repository from Roomote, then bootstrap an empty repository and configure its environment automatically. +- Manage GitHub labels, milestones, and project status values safely from Roomote tasks. +- Find the running Roomote version easily, verify Teams credentials during setup, and start sandboxes more reliably through transient broker failures. + +### Minor changes + +- Announce newly published Roomote releases in Discord with the release title, notes, link, timestamp, and Roomote branding when a main-channel webhook is configured. +- Set up and manage Linear from onboarding or Settings, link user accounts, and start app-mention, issue-delegation, scheduled, and direct Linear tasks with the correct workspace and account context. +- Create a new GitHub repository or fork an existing one from Roomote, then automatically detect empty repositories, add their initial commit, and configure a working environment. +- Manage GitHub labels, milestones, and project status values from Roomote tasks with scoped credentials, confirmation for destructive changes, and read-back verification. +- See the running Roomote version from the signed-in user menu, open release details, and revisit the latest What's New notice without administrator access. + +### Patch changes + +- Hide self-hosted license controls from cloud deployments while keeping license management available to self-hosted administrators. +- Make task conversations easier to follow by showing request-input questions as distinct quoted context, refining mobile task chrome, quoting web follow-ups in GitHub replies, and keeping internal routing context out of Slack and Discord quotes. +- Retry transient compute-broker upload failures during sandbox startup so momentary upstream errors no longer prevent task environments from starting. +- Choose the model used by the Onboarding Agent when editing an existing environment, matching the model selection already available when creating one. +- Verify Microsoft Teams bot credentials with Microsoft before saving them, so a wrong app id, client secret, or tenant id fails the save with a message naming the field instead of reporting a configured bot that cannot authenticate. Teams settings now also reports when the saved credentials stop authenticating, and explains why the Teams app package cannot be pre-filled from a malformed App (Client) ID. + ## 0.25.0 (2026-07-28) This release expands account and diagnostics configuration while making task execution and Slack automation more reliable. diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts new file mode 100644 index 000000000..5fa5fac32 --- /dev/null +++ b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { mockFindFirst, mockSyncGitHubInstallation } = vi.hoisted(() => ({ + mockFindFirst: vi.fn(), + mockSyncGitHubInstallation: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + githubInstallations: { + findFirst: mockFindFirst, + }, + }, + }, + githubInstallations: { + installationId: 'installation_id', + }, + eq: (column: unknown, value: unknown) => ({ eq: [column, value] }), +})); + +vi.mock('@roomote/github', () => ({ + syncGitHubInstallation: mockSyncGitHubInstallation, +})); + +import { handleInstallationRepositoriesChange } from '../handleInstallationRepositoriesChange'; + +describe('handleInstallationRepositoriesChange', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockResolvedValue({ installedByUserId: 'user-1' }); + mockSyncGitHubInstallation.mockResolvedValue({ + success: true, + githubInstallation: {}, + repositories: [{ id: 'repo-1' }, { id: 'repo-2' }], + }); + }); + + it('resyncs the installation attributed to the installing user', async () => { + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(mockSyncGitHubInstallation).toHaveBeenCalledWith({ + userId: 'user-1', + installationId: 42, + }); + expect(response.status).toBe('ok'); + expect(response.metadata).toEqual({ repositoryCount: 2 }); + }); + + it('short-circuits when the payload has no installation id', async () => { + const response = await handleInstallationRepositoriesChange({}); + + expect(response).toEqual({ status: 'ok', message: 'missing_installation' }); + expect(mockFindFirst).not.toHaveBeenCalled(); + expect(mockSyncGitHubInstallation).not.toHaveBeenCalled(); + }); + + it('short-circuits for installations this deployment has not synced', async () => { + mockFindFirst.mockResolvedValue(undefined); + + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(response).toEqual({ status: 'ok', message: 'unknown_installation' }); + expect(mockSyncGitHubInstallation).not.toHaveBeenCalled(); + }); + + it('reports an error when the resync fails', async () => { + mockSyncGitHubInstallation.mockResolvedValue({ + success: false, + error: 'boom', + }); + + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(response.status).toBe('error'); + expect(response.message).toContain('boom'); + }); +}); diff --git a/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts b/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts index b67111a9e..d4fe060ea 100644 --- a/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts +++ b/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts @@ -97,6 +97,34 @@ describe('isFromKnownInstallation', () => { ).resolves.toBe(false); }); + it('allows installation_repositories events from a known installation', async () => { + mockFindFirst.mockResolvedValue({ id: 'installation-row' }); + + const payload = JSON.stringify({ + action: 'added', + installation: { id: 456 }, + repositories_added: [{ id: 1, full_name: 'acme/new-repo' }], + }); + + await expect( + isFromKnownInstallation('installation_repositories', payload), + ).resolves.toBe(true); + }); + + it('rejects repository.created events from an unknown installation', async () => { + mockFindFirst.mockResolvedValue(undefined); + + const payload = JSON.stringify({ + action: 'created', + installation: { id: 789 }, + repository: { id: 1, full_name: 'acme/new-repo' }, + }); + + await expect(isFromKnownInstallation('repository', payload)).resolves.toBe( + false, + ); + }); + it('rejects non-created installation events from an unknown installation', async () => { mockFindFirst.mockResolvedValue(undefined); diff --git a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts new file mode 100644 index 000000000..aa51b2ea3 --- /dev/null +++ b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts @@ -0,0 +1,53 @@ +import { db, eq, githubInstallations } from '@roomote/db/server'; +import * as GitHub from '@roomote/github'; + +import type { WebhookResponse } from '../../types'; + +interface InstallationRepositoriesChangePayload { + installation?: { id?: number } | null; +} + +/** + * Resync an installation's repository list when its accessible repositories + * change on GitHub: `installation_repositories.added/removed` (selected-repos + * installs) and `repository.created/deleted/renamed` (all-repos installs emit + * these instead). A full resync is used rather than a narrow upsert because + * these payloads omit fields the `repositories` row needs (default branch, + * clone URL), and `syncRepositories` already handles upsert + deactivation. + */ +export async function handleInstallationRepositoriesChange( + payload: InstallationRepositoriesChangePayload, +): Promise { + const installationId = payload.installation?.id; + + if (typeof installationId !== 'number') { + return { status: 'ok', message: 'missing_installation' }; + } + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.installationId, installationId), + columns: { installedByUserId: true }, + }); + + if (!installation) { + return { status: 'ok', message: 'unknown_installation' }; + } + + const result = await GitHub.syncGitHubInstallation({ + userId: installation.installedByUserId, + installationId, + }); + + if (!result.success) { + return { + status: 'error', + message: `Failed to resync installation ${installationId}: ${result.error}`, + }; + } + + return { + status: 'ok', + message: `Resynced installation ${installationId}`, + metadata: { repositoryCount: result.repositories.length }, + }; +} diff --git a/apps/api/src/handlers/github/handlePrComment.ts b/apps/api/src/handlers/github/handlePrComment.ts index 6efcfd116..91e2f201a 100644 --- a/apps/api/src/handlers/github/handlePrComment.ts +++ b/apps/api/src/handlers/github/handlePrComment.ts @@ -854,6 +854,7 @@ async function deliverFollowUpToExistingTask({ taskId, userId, message, + quoteText = message, status, taskPhase, commenterDisplayName, @@ -861,6 +862,7 @@ async function deliverFollowUpToExistingTask({ taskId: string; userId?: string | null; message: string; + quoteText?: string; status: RunStatus; taskPhase: string | null; commenterDisplayName?: string; @@ -890,6 +892,7 @@ async function deliverFollowUpToExistingTask({ taskId, userId: senderUserId, message, + quoteText, senderMode: 'github_pr_follow_up', ...(commenterDisplayName ? { workerQuoteUserName: commenterDisplayName } @@ -899,6 +902,7 @@ async function deliverFollowUpToExistingTask({ taskId, userId: senderUserId, message, + quoteText, senderMode: 'github_pr_follow_up', ...(commenterDisplayName ? { workerQuoteUserName: commenterDisplayName } @@ -932,12 +936,14 @@ async function resumeExistingTaskAndDeliverFollowUp({ userId, sourceRunId, message, + quoteText = message, resumePromptFallbackTask, }: { taskId: string; userId?: string | null; sourceRunId: number; message: string; + quoteText?: string; resumePromptFallbackTask: SnapshotResumePromptFallbackTask; }) { const sourceRun = await findLatestTaskRun(taskId, { @@ -984,6 +990,7 @@ async function resumeExistingTaskAndDeliverFollowUp({ taskId, userId, message, + quoteText, status: sourceRun.status, taskPhase: sourceRun.taskPhase, }); @@ -1028,6 +1035,7 @@ async function resumeExistingTaskAndDeliverFollowUp({ // Carry the follow-up on the resume run itself so the resumed worker can // send it after the harness session is actually ready. resumePrompt: message, + resumeQuoteText: quoteText, resumePromptSource: 'github', resumePromptFallbackTask, } satisfies TaskPayload; @@ -1504,12 +1512,14 @@ export async function handlePrComment( userId: reviewer.properties.userId, sourceRunId: activePrOwner.runId, message: followUpMessage, + quoteText: mention.body ?? '', resumePromptFallbackTask, }) : await deliverFollowUpToExistingTask({ taskId: activePrOwner.taskId, userId: reviewer.properties.userId, message: followUpMessage, + quoteText: mention.body ?? '', status: activePrOwner.status, taskPhase: activePrOwner.taskPhase, commenterDisplayName, diff --git a/apps/api/src/handlers/github/index.ts b/apps/api/src/handlers/github/index.ts index da069691b..838e4e10a 100644 --- a/apps/api/src/handlers/github/index.ts +++ b/apps/api/src/handlers/github/index.ts @@ -38,6 +38,7 @@ import { handleWorkflowRunCompleted } from './handleWorkflowRunCompleted'; // Repository metadata sync: import { handleRepositoryEdited } from './handleRepositoryEdited'; +import { handleInstallationRepositoriesChange } from './handleInstallationRepositoriesChange'; // Utilities: import { isFromKnownInstallation } from './isFromKnownInstallation'; @@ -462,6 +463,26 @@ github.post('/', async (c) => { ), ); + // Keep the stored repository list in sync as repos appear, disappear, or + // change access. Selected-repos installs emit `installation_repositories`; + // all-repos installs emit `repository.created/deleted` instead. Not gated + // on isRepoSkipped: the row must exist even for skipped repos. + webhooks.on( + ['installation_repositories.added', 'installation_repositories.removed'], + ({ id, name, payload }) => + recordWebhook(id, `${name}.${payload.action}`, payload, () => + handleInstallationRepositoriesChange(payload), + ), + ); + + webhooks.on( + ['repository.created', 'repository.deleted', 'repository.renamed'], + ({ id, name, payload }) => + recordWebhook(id, `${name}.${payload.action}`, payload, () => + handleInstallationRepositoriesChange(payload), + ), + ); + webhooks.on('workflow_run.completed', ({ id, name, payload }) => recordWebhook(id, `${name}.${payload.action}`, payload, async () => { if (isRepoSkipped(payload.repository.full_name)) { diff --git a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts index 422f87adb..8afb86e0f 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts @@ -13,6 +13,7 @@ const { findLinearUserMcpConnectionByIdentityMock, getValidAccessTokenMock, createMcpOauthReplayMock, + resolveDeploymentEnvVarMock, } = vi.hoisted(() => ({ redisMock: { eval: vi.fn().mockResolvedValue(null), @@ -31,6 +32,7 @@ const { findLinearUserMcpConnectionByIdentityMock: vi.fn(), getValidAccessTokenMock: vi.fn(), createMcpOauthReplayMock: vi.fn(), + resolveDeploymentEnvVarMock: vi.fn().mockResolvedValue('test-linear-secret'), })); vi.mock('@roomote/env', async (importOriginal) => { @@ -154,6 +156,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { }, linearAuthTokens: {}, webhooks: { id: 'id', deliveryId: 'deliveryId' }, + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, eq: vi.fn(), and: vi.fn(), }; @@ -293,6 +296,11 @@ describe('linear routed task startup', () => { ); expect(response.status).toBe(200); + expect(resolveDeploymentEnvVarMock).toHaveBeenCalledWith( + 'R_LINEAR_WEBHOOK_SECRET', + db, + { R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret' }, + ); expect(emitThought).toHaveBeenCalledWith( 'session-1', 'Getting started...', @@ -319,4 +327,155 @@ describe('linear routed task startup', () => { expect(emitElicitation).not.toHaveBeenCalled(); expect(redisMock.set).not.toHaveBeenCalled(); }); + + it('uses the session user when a direct delegation has no creator', async () => { + vi.mocked(routeTask).mockResolvedValue({ + status: 'routed', + result: { + workspace: { type: 'all_repositories' }, + reasoning: 'Best fit for review work', + }, + }); + + const payload = makePayload({ + agentSession: { + ...makePayload().agentSession, + creator: undefined, + user: { id: 'linear-user-1', name: 'Linear User' }, + }, + }); + const { rawBody, headers } = createSignedRequest(payload); + + const response = await app.request( + new Request('http://localhost/linear', { + method: 'POST', + headers, + body: rawBody, + }), + ); + + expect(response.status).toBe(200); + expect(findLinearUserMcpConnectionByIdentityMock).toHaveBeenCalledWith({ + linearUserId: 'linear-user-1', + linearOrganizationId: 'linear-org-1', + }); + expect(createLinearAgentRun).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + ); + }); + + it('starts a trusted Linear automation without a human identity', async () => { + vi.mocked(routeTask).mockResolvedValue({ + status: 'routed', + result: { + workspace: { type: 'all_repositories' }, + reasoning: 'Best fit for review work', + }, + }); + + const payload = makePayload({ + agentSession: { + ...makePayload().agentSession, + creator: undefined, + user: undefined, + }, + }); + const { rawBody, headers } = createSignedRequest(payload); + + const response = await app.request( + new Request('http://localhost/linear', { + method: 'POST', + headers, + body: rawBody, + }), + ); + + expect(response.status).toBe(200); + expect(findLinearUserMcpConnectionByIdentityMock).not.toHaveBeenCalled(); + expect(createLinearAgentRun).toHaveBeenCalledWith( + expect.objectContaining({ userId: undefined }), + ); + }); + + it('uses all repositories when automation routing fails', async () => { + vi.mocked(routeTask).mockRejectedValue(new Error('routing unavailable')); + + const payload = makePayload({ + agentSession: { + ...makePayload().agentSession, + creator: undefined, + user: undefined, + }, + }); + const { rawBody, headers } = createSignedRequest(payload); + + const response = await app.request( + new Request('http://localhost/linear', { + method: 'POST', + headers, + body: rawBody, + }), + ); + + expect(response.status).toBe(200); + expect(createLinearAgentRun).toHaveBeenCalledWith( + expect.objectContaining({ + userId: undefined, + repo: ALL_REPOSITORIES, + }), + ); + }); + + it('rejects an unidentified prompted session', async () => { + const payload = makePayload({ + action: 'prompted', + agentSession: { + ...makePayload().agentSession, + creator: undefined, + user: undefined, + }, + }); + const { rawBody, headers } = createSignedRequest(payload); + + const response = await app.request( + new Request('http://localhost/linear', { + method: 'POST', + headers, + body: rawBody, + }), + ); + + expect(response.status).toBe(200); + expect(findLinearUserMcpConnectionByIdentityMock).not.toHaveBeenCalled(); + expect(createLinearAgentRun).not.toHaveBeenCalled(); + }); + + it('requires account linking when a direct delegation user is unlinked', async () => { + findLinearUserMcpConnectionByIdentityMock.mockResolvedValue(null); + + const payload = makePayload({ + agentSession: { + ...makePayload().agentSession, + creator: undefined, + user: { id: 'linear-user-2', name: 'Unlinked User' }, + }, + }); + const { rawBody, headers } = createSignedRequest(payload); + + const response = await app.request( + new Request('http://localhost/linear', { + method: 'POST', + headers, + body: rawBody, + }), + ); + + expect(response.status).toBe(200); + expect(emitElicitation).toHaveBeenCalledWith( + 'session-1', + 'Please link your Roomote account to continue.', + expect.objectContaining({ signal: 'auth' }), + ); + expect(createLinearAgentRun).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts index dd0247d0c..88dbe8533 100644 --- a/apps/api/src/handlers/linear/index.ts +++ b/apps/api/src/handlers/linear/index.ts @@ -15,15 +15,16 @@ import { import { Env } from '@roomote/env'; import { type RoutingDebugInfo, - type RoutingWorkspace, enqueueTask, - routeTask, - buildLinearRoutingContext, } from '@roomote/cloud-agents/server'; import { buildTaskStartingText } from '@roomote/communication/chat-messages'; import { getRedis } from '@roomote/redis'; import { postRouterDebugMessage } from '@roomote/slack'; -import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; +import { + db, + resolveDeploymentEnvVar, + setTrustedRunActingUserOnSuccess, +} from '@roomote/db/server'; import { createMcpOauthReplay, findLinearDeploymentMcpConnectionByIdentity, @@ -48,12 +49,14 @@ import { cancelLinearTaskRun, parseAgentSessionEventPayload, createLinearAgentRun, - startElicitationFallback, findPendingSelection, handleElicitationResponse, deletePendingSelection, enrichSessionComments, + resolveLinearTaskDestination, type CreateLinearAgentRunResult, + type LinearWorkspaceSelection, + type ResolvedLinearTaskDestination, } from '@roomote/linear'; import type { WebhookResponse } from '../../types'; @@ -114,37 +117,7 @@ const AUTH_TOKEN_EXPIRY_MS = 15 * 60 * 1000; * Result of workspace mapping that properly distinguishes between * repository names and environment IDs. */ -interface WorkspaceSelection { - repo?: string; - environmentId?: string; -} - -/** - * Maps a routing workspace to the appropriate repo/environmentId fields. - * - * @param workspace - The workspace selection from the LLM router - * @returns Object with either repo or environmentId populated - */ -function mapWorkspaceToSelection( - workspace: RoutingWorkspace, -): WorkspaceSelection { - switch (workspace.type) { - case 'environment': - return { environmentId: workspace.id }; - case 'all_repositories': - return { repo: ALL_REPOSITORIES }; - } -} - -/** - * Derives the workspace type from a WorkspaceSelection. - */ -function deriveWorkspaceType( - ws: WorkspaceSelection, -): 'environment' | 'all_repositories' { - if (ws.environmentId) return 'environment'; - return 'all_repositories'; -} +type WorkspaceSelection = LinearWorkspaceSelection; /** * Maps an elicitation workspace type + value to the appropriate WorkspaceSelection. @@ -197,16 +170,7 @@ function postLinearFinalRouterDebug({ }); } -interface RoutedLinearTask { - workspaceSelection: WorkspaceSelection; - workspaceDisplayName: string; - workspaceType: 'environment' | 'all_repositories'; - kickoffMessage?: string; - reasoning?: string; - routingDebug?: RoutingDebugInfo; - routingDurationMs?: number; - userRoute?: string; -} +type RoutedLinearTask = ResolvedLinearTaskDestination; async function startLinearTask({ linearClient, @@ -217,7 +181,7 @@ async function startLinearTask({ }: { linearClient: LinearClient; payload: AgentSessionEventPayload; - userId: string; + userId?: string; routedTask: RoutedLinearTask; agentSession: AgentSessionEventPayload['agentSession']; }): Promise { @@ -302,7 +266,11 @@ linear.post('/', async (c) => { // Verify webhook signature const signature = headers['linear-signature'] ?? ''; - const webhookSecret = Env.R_LINEAR_WEBHOOK_SECRET; + const webhookSecret = await resolveDeploymentEnvVar( + 'R_LINEAR_WEBHOOK_SECRET', + db, + { R_LINEAR_WEBHOOK_SECRET: Env.R_LINEAR_WEBHOOK_SECRET }, + ); if (!webhookSecret) { console.error('[LinearWebhook] R_LINEAR_WEBHOOK_SECRET not configured'); @@ -432,12 +400,14 @@ async function handleAgentSessionEvent( return handleStopSignal(sessionId, linearClient, agentSession.issue.id); } - // Extract Linear user ID from the webhook payload - always use agentSession.creator - const linearUserId = agentSession.creator?.id; + // Direct issue delegations can omit human identity entirely. The signed + // webhook and org-level connection establish the trusted automation caller. + const linearUserId = + agentSession.creator?.id ?? agentSession.user?.id ?? agentActivity?.userId; - if (!linearUserId) { + if (!linearUserId && action === 'prompted') { console.error( - `[LinearWebhook] No Linear user ID found for session ${sessionId} - agentSession.creator is missing`, + `[LinearWebhook] No Linear user ID found for prompted session ${sessionId}`, ); await linearClient.emitError( sessionId, @@ -446,95 +416,107 @@ async function handleAgentSessionEvent( return { status: 'error', message: 'No Linear user ID in payload' }; } - // Check if this Linear user is linked to a Roomote account - console.log( - `[LinearWebhook] Checking user mapping for linearUserId=${linearUserId}`, - ); + let userId: string | undefined; - const userMapping = await findLinearUserMcpConnectionByIdentity({ - linearUserId, - linearOrganizationId: organizationId, - }); + if (linearUserId) { + console.log( + `[LinearWebhook] Checking user mapping for linearUserId=${linearUserId}`, + ); - console.log( - `[LinearWebhook] User mapping result: ${userMapping ? `found (userId=${userMapping.userId})` : 'not found'}`, - ); + const userMapping = await findLinearUserMcpConnectionByIdentity({ + linearUserId, + linearOrganizationId: organizationId, + }); - if (!userMapping) { console.log( - `[LinearWebhook] Linear user ${linearUserId} not linked - emitting auth signal`, + `[LinearWebhook] User mapping result: ${userMapping ? `found (userId=${userMapping.userId})` : 'not found'}`, ); - const authToken = generateAuthToken(); + if (!userMapping) { + console.log( + `[LinearWebhook] Linear user ${linearUserId} not linked - emitting auth signal`, + ); - await createMcpOauthReplay({ - token: authToken, - payload, // Store the original payload to replay after auth - userId: null, - mcpId: 'linear', - connectionId: null, - connectionRole: LINEAR_USER_CONNECTION_ROLE, - sessionId, - redirectTo: null, - metadata: { - linearUserId, - linearOrganizationId: organizationId, - }, - expiresAt: new Date(Date.now() + AUTH_TOKEN_EXPIRY_MS), - }); + const authToken = generateAuthToken(); - const authUrl = `${getAuthBaseUrl()}/api/mcp-oauth/replay/${authToken}`; - console.log(`[LinearWebhook] Auth URL created for session ${sessionId}`); + await createMcpOauthReplay({ + token: authToken, + payload, // Store the original payload to replay after auth + userId: null, + mcpId: 'linear', + connectionId: null, + connectionRole: LINEAR_USER_CONNECTION_ROLE, + sessionId, + redirectTo: null, + metadata: { + linearUserId, + linearOrganizationId: organizationId, + }, + expiresAt: new Date(Date.now() + AUTH_TOKEN_EXPIRY_MS), + }); - // Emit an auth elicitation signal - console.log( - `[LinearWebhook] Emitting auth elicitation for session ${sessionId}`, - ); - const authResult = await linearClient.emitElicitation( - sessionId, - `Please link your ${PRODUCT_NAME} account to continue.`, - { - signal: 'auth', - signalMetadata: { - url: authUrl, - userId: linearUserId, - providerName: PRODUCT_NAME, + const authUrl = `${getAuthBaseUrl()}/api/mcp-oauth/replay/${authToken}`; + console.log(`[LinearWebhook] Auth URL created for session ${sessionId}`); + + // Emit an auth elicitation signal + console.log( + `[LinearWebhook] Emitting auth elicitation for session ${sessionId}`, + ); + const authResult = await linearClient.emitElicitation( + sessionId, + `Please link your ${PRODUCT_NAME} account to continue.`, + { + signal: 'auth', + signalMetadata: { + url: authUrl, + userId: linearUserId, + providerName: PRODUCT_NAME, + }, }, - }, - ); + ); - console.log( - `[LinearWebhook] Auth elicitation result: ${JSON.stringify(authResult)}`, - ); + console.log( + `[LinearWebhook] Auth elicitation result: ${JSON.stringify(authResult)}`, + ); + + if (!authResult.success) { + console.error( + `[LinearWebhook] Failed to emit auth signal: ${authResult.error}`, + ); + } - if (!authResult.success) { + return { status: 'ok' }; + } + + if (!userMapping.userId) { console.error( - `[LinearWebhook] Failed to emit auth signal: ${authResult.error}`, + `[LinearWebhook] Linear link for user ${linearUserId} is missing a Roomote user id`, + ); + await linearClient.emitError( + sessionId, + 'Your Linear account link is incomplete. Please reconnect your account and try again.', ); + return { status: 'error', message: 'Linked user id is missing' }; } - return { status: 'ok' }; - } - - // User is linked - update userId to the linked user - const userId = userMapping.userId; - if (!userId) { - console.error( - `[LinearWebhook] Linear link for user ${linearUserId} is missing a Roomote user id`, + userId = userMapping.userId; + console.log( + `[LinearWebhook] Linear user ${linearUserId} is linked to Roomote user ${userId}`, ); - await linearClient.emitError( - sessionId, - 'Your Linear account link is incomplete. Please reconnect your account and try again.', + } else { + console.log( + `[LinearWebhook] Starting trusted Linear automation for session ${sessionId}`, ); - return { status: 'error', message: 'Linked user id is missing' }; } - console.log( - `[LinearWebhook] Linear user ${linearUserId} is linked to Roomote user ${userId}`, - ); - // Check if this is a follow-up prompt for an existing session if (action === 'prompted') { + if (!userId) { + return { + status: 'error', + message: 'No linked user for prompted session', + }; + } // First, check for an active task run. When a job is running (e.g. waiting // on ask_followup_question), the user's reply must be delivered to it // immediately. This takes priority over routing confirmation and @@ -907,189 +889,41 @@ async function handleAgentSessionEvent( agentSession, ); - let workspaceSelection: WorkspaceSelection = { repo: ALL_REPOSITORIES }; - console.log(`[LinearWebhook] Attempting to route Linear task`); - - try { - // Production routing already resolves the correct workspace, so keep the - // existing Linear task description input and only change the kickoff flow. - const taskDescription = - agentSession.comment?.body || - agentSession.issue.description || - agentSession.issue.title; - - // Build routing context with all relevant Linear data - const routingContext = await buildLinearRoutingContext({ - userId, - taskDescription, - issueIdentifier: agentSession.issue.identifier, - issueTitle: agentSession.issue.title, - issueDescription: agentSession.issue.description, - projectName: agentSession.issue.project?.name, - teamName: agentSession.issue.team?.name, - guidance: agentSession.guidance, - previousComments: enrichedSession.previousComments?.map((c) => ({ - body: c.body, - username: c.user?.name, - })), - apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, - }); - - // Attempt LLM routing - const routingStart = Date.now(); - const routingDecision = await routeTask(routingContext); - const routingDurationMs = Date.now() - routingStart; - - if (routingDecision.status === 'platform_answer') { - const responseResult = await linearClient.emitResponse( - sessionId, - routingDecision.result.answer, - ); - - if (!responseResult.success) { - console.error( - `[LinearWebhook] Failed to emit platform answer to Linear: ${responseResult.error}`, - ); - } - - return { status: 'ok' }; - } - - if (routingDecision.status === 'routed') { - const { result } = routingDecision; - - console.log( - `[LinearWebhook] LLM routing decision: ` + - `workspace=${result.workspace.type}${result.workspace.type === 'environment' ? `(${result.workspace.name})` : ''}, ` + - `reasoning="${result.reasoning}"`, - ); - - const ws = mapWorkspaceToSelection(result.workspace); - - const wsDesc = - ws.repo === ALL_REPOSITORIES - ? 'all repos' - : ws.repo || - (result.workspace.type === 'environment' - ? result.workspace.name - : `environment(${ws.environmentId})`); - - return startLinearTask({ - linearClient, - payload, - userId, - routedTask: { - workspaceSelection: ws, - workspaceDisplayName: wsDesc, - workspaceType: deriveWorkspaceType(ws), - ...(result.kickoffMessage - ? { kickoffMessage: result.kickoffMessage } - : {}), - reasoning: result.reasoning, - routingDebug: result.debug, - routingDurationMs, - }, - agentSession: enrichedSession, - }); - } else { - console.log( - `[LinearWebhook] LLM routing fell back: ${routingDecision.reason}`, - ); - } - } catch (routingError) { - console.error( - `[LinearWebhook] LLM routing error, falling back to default:`, - routingError instanceof Error - ? routingError.message - : String(routingError), - ); - } - - // If routing was not used or failed, use the elicitation fallback flow - console.log( - `[LinearWebhook] LLM routing not available or failed, starting elicitation fallback for session ${sessionId}`, - ); - - const fallbackResult = await startElicitationFallback({ - sessionId, - linearOrganizationId: organizationId, - userId, + const destinationResult = await resolveLinearTaskDestination({ payload, + agentSession: enrichedSession, + userId, linearClient, + apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, }); - if (fallbackResult.status === 'error') { - console.error( - `[LinearWebhook] Elicitation fallback error: ${fallbackResult.message}`, - ); - - const errorResult = await linearClient.emitError( + if (destinationResult.status === 'platform_answer') { + const responseResult = await linearClient.emitResponse( sessionId, - `Failed to start workspace selection: ${fallbackResult.message}`, + destinationResult.answer, ); - if (!errorResult.success) { + if (!responseResult.success) { console.error( - `[LinearWebhook] Failed to emit error to Linear: ${errorResult.error}`, + `[LinearWebhook] Failed to emit platform answer to Linear: ${responseResult.error}`, ); } - return { status: 'error', message: fallbackResult.message }; + return { status: 'ok' }; } - // Check if the elicitation completed immediately (e.g., only one workspace) - if (fallbackResult.pendingSelection.step === 'completed') { - const selectedRepo = - fallbackResult.pendingSelection.selectedRepo ?? ALL_REPOSITORIES; - - const wsOptions = fallbackResult.pendingSelection - .workspaceOptions as Array<{ - type: 'all' | 'environment'; - id: string; - name: string; - }> | null; - - const matchedWs = wsOptions?.find((ws) => ws.id === selectedRepo); - - if (matchedWs?.type === 'environment') { - workspaceSelection = { environmentId: matchedWs.id }; - } else if (selectedRepo === ALL_REPOSITORIES) { - workspaceSelection = { repo: ALL_REPOSITORIES }; - } else { - workspaceSelection = { repo: selectedRepo }; - } - - console.log( - `[LinearWebhook] Elicitation auto-completed with workspace=${JSON.stringify(workspaceSelection)}`, - ); - - await deletePendingSelection(sessionId); - } else { + if (destinationResult.status === 'awaiting_selection') { console.log( `[LinearWebhook] Elicitation started, awaiting workspace selection for session ${sessionId}`, ); - return { status: 'ok' }; } - // Create the task run - const runResult = await createLinearAgentRun({ - agentSession: enrichedSession, - payload, - userId, - repo: workspaceSelection.repo, - environmentId: workspaceSelection.environmentId, - }); - - if (runResult.status === 'error') { - console.error( - `[LinearWebhook] Failed to create task run: ${runResult.message}`, - ); - + if (destinationResult.status === 'error') { const errorResult = await linearClient.emitError( sessionId, - `Failed to start agent: ${runResult.message}`, + `Failed to start workspace selection: ${destinationResult.message}`, ); if (!errorResult.success) { @@ -1098,20 +932,16 @@ async function handleAgentSessionEvent( ); } - return { status: 'error', message: runResult.message }; + return { status: 'error', message: destinationResult.message }; } - console.log( - `[LinearWebhook] Created ${describeLinearRunResult(runResult)} for session ${sessionId}`, - ); - - await updateLinearSessionTaskUrlForDirectLaunch({ + return startLinearTask({ linearClient, - sessionId, - runResult, + payload, + userId, + routedTask: destinationResult.destination, + agentSession: enrichedSession, }); - - return { status: 'ok' }; } /** diff --git a/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts b/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts index ec4f933bc..b48e4b5e0 100644 --- a/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts @@ -222,6 +222,7 @@ describe('sendMessageToTask', () => { ).toBeLessThan(mockSendPromptMutate.mock.invocationCallOrder[0]!); expect(mockSendPromptMutate).toHaveBeenCalledWith({ prompt: 'Continue as the new sender.', + quoteText: 'Continue as the new sender.', autoSteerWhenQueued: true, }); }); @@ -362,6 +363,7 @@ describe('sendMessageToTask', () => { }); expect(mockSendPromptMutate).toHaveBeenCalledWith({ prompt: 'Please keep the existing payout logic.', + quoteText: 'Please keep the existing payout logic.', source: 'web', clientMessageId: 'client-1', }); @@ -394,6 +396,7 @@ describe('sendMessageToTask', () => { expect(mockTrackLatestUserMessageForSlackQuote).not.toHaveBeenCalled(); expect(mockSendPromptMutate).toHaveBeenCalledWith({ prompt: 'This stays in the web UI only.', + quoteText: 'This stays in the web UI only.', }); }); @@ -495,6 +498,7 @@ describe('sendMessageToTask', () => { expect(mockTrackLatestUserMessageForSlackQuote).not.toHaveBeenCalled(); expect(mockSendPromptMutate).toHaveBeenCalledWith({ prompt: 'Please keep the existing payout logic.', + quoteText: 'Please keep the existing payout logic.', }); }); @@ -519,6 +523,8 @@ describe('sendMessageToTask', () => { expect(mockTrackLatestUserMessageForSlackQuote).not.toHaveBeenCalled(); expect(mockSendPromptMutate).toHaveBeenCalledWith({ prompt: 'Route this through the existing PR task.', + quoteText: + 'Route this through the existing PR task.', userName: 'Ada Lovelace', }); }); @@ -550,6 +556,7 @@ describe('sendMessageToTask', () => { }); expect(mockSteerTaskMutate).toHaveBeenCalledWith({ prompt: 'Pause the implementation and inspect the failing test.', + quoteText: 'Pause the implementation and inspect the failing test.', }); }); @@ -608,6 +615,8 @@ describe('sendMessageToTask', () => { expect(mockUserFindFirst).toHaveBeenCalledWith(expect.anything()); expect(mockSteerTaskMutate).toHaveBeenCalledWith({ prompt: 'Route this through the existing PR task.', + quoteText: + 'Route this through the existing PR task.', userName: 'Ada Lovelace', }); }); @@ -630,6 +639,8 @@ describe('sendMessageToTask', () => { expect(mockUserFindFirst).not.toHaveBeenCalled(); expect(mockSteerTaskMutate).toHaveBeenCalledWith({ prompt: 'Route this through the existing PR task.', + quoteText: + 'Route this through the existing PR task.', userName: 'octocat', }); }); @@ -651,6 +662,8 @@ describe('sendMessageToTask', () => { }); expect(mockSteerTaskMutate).toHaveBeenCalledWith({ prompt: 'Route this through the existing PR task.', + quoteText: + 'Route this through the existing PR task.', }); }); diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index c7ce752f5..1ee30b6ab 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -1,6 +1,12 @@ import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { z } from 'zod'; +import { + claimLatestUserMessageForReplyQuote, + completeClaimedLatestUserMessageForReplyQuote, + restoreClaimedLatestUserMessageForReplyQuote, +} from '@roomote/communication/messages'; +import { resolveSourceControlProviderFromPayload } from '@roomote/types'; import { createOrUpdateSourceControlPullRequestForTaskRun, @@ -27,6 +33,27 @@ import { } from '../mcp/proxy-utils'; import { logHandlerError } from '../utils'; +const GITHUB_REPLY_QUOTE_MAX_LENGTH = 280; + +function formatGitHubReplyQuote(params: { + userName: string; + text: string; +}): string | null { + const userName = params.userName.replace(/\s+/g, ' ').trim(); + const text = params.text.trim(); + + if (!userName || !text) { + return null; + } + + const truncated = + text.length <= GITHUB_REPLY_QUOTE_MAX_LENGTH + ? text + : `${text.slice(0, GITHUB_REPLY_QUOTE_MAX_LENGTH - 3).trimEnd()}...`; + + return `> **${userName}:** ${truncated.replaceAll('\n', '\n> ')}`; +} + /** * POST /api/mcp/tasks/:taskId/source_control * @@ -66,6 +93,57 @@ export async function manageSourceControl( runId: auth.authContext.runId, taskId, }); + const isGitHubTask = + resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'; + const bodyInput = 'body' in input ? input : null; + const shouldQuote = + isGitHubTask && + (input.action === 'reply_to_pull_request_comment' || + input.action === 'create_pull_request_comment' || + input.action === 'create_issue_comment') && + typeof bodyInput?.body === 'string'; + const pendingQuoteClaim = shouldQuote + ? await claimLatestUserMessageForReplyQuote('github', taskRun.id) + : null; + const pendingQuote = pendingQuoteClaim?.message ?? null; + + if (pendingQuote && typeof bodyInput?.body === 'string') { + const quote = formatGitHubReplyQuote(pendingQuote); + if (quote) { + bodyInput.body = `${quote}\n\n${bodyInput.body}`; + } + } + + const restoreQuoteAfterFailure = async () => { + if (pendingQuoteClaim) { + await restoreClaimedLatestUserMessageForReplyQuote( + 'github', + taskRun.id, + pendingQuoteClaim, + ); + } + }; + + const completeQuoteAfterSuccess = async () => { + if (pendingQuoteClaim) { + await completeClaimedLatestUserMessageForReplyQuote( + 'github', + taskRun.id, + pendingQuoteClaim, + ); + } + }; + + const runWithQuoteRestorationOnFailure = async ( + operation: () => Promise, + ): Promise => { + try { + return await operation(); + } catch (error) { + await restoreQuoteAfterFailure(); + throw error; + } + }; switch (input.action) { case 'create_or_update_pull_request': @@ -88,22 +166,28 @@ export async function manageSourceControl( case 'create_pull_request_comment': case 'resolve_pull_request_thread': case 'submit_pull_request_review': - case 'update_pull_request_comment': - return c.json( - await writeSourceControlPullRequestForTaskRun({ + case 'update_pull_request_comment': { + const writeResult = await runWithQuoteRestorationOnFailure(() => + writeSourceControlPullRequestForTaskRun({ taskRun, input, }), ); + await completeQuoteAfterSuccess(); + return c.json(writeResult); + } case 'get_issue': case 'list_issue_comments': - case 'create_issue_comment': - return c.json( - await manageSourceControlIssueForTaskRun({ + case 'create_issue_comment': { + const issueResult = await runWithQuoteRestorationOnFailure(() => + manageSourceControlIssueForTaskRun({ taskRun, input, }), ); + await completeQuoteAfterSuccess(); + return c.json(issueResult); + } } } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/api/src/handlers/tasks/sendMessageToTask.ts b/apps/api/src/handlers/tasks/sendMessageToTask.ts index 45112394c..e9959b1ff 100644 --- a/apps/api/src/handlers/tasks/sendMessageToTask.ts +++ b/apps/api/src/handlers/tasks/sendMessageToTask.ts @@ -462,6 +462,7 @@ async function resumeTaskFromSnapshot({ taskId, userId, message, + quoteText, images, source, clientMessageId, @@ -472,6 +473,7 @@ async function resumeTaskFromSnapshot({ taskId: string; userId: string; message: string; + quoteText: string; images?: string[]; source?: string; clientMessageId?: string; @@ -567,7 +569,7 @@ async function resumeTaskFromSnapshot({ payload, slackThreadTs: channelBindings?.slackThreadTs ?? null, userId, - message, + message: quoteText, senderMode, }); @@ -720,6 +722,7 @@ export async function sendMessageToTask({ userId, authContext, message, + quoteText = message, images, source, clientMessageId, @@ -730,6 +733,7 @@ export async function sendMessageToTask({ userId: string; authContext?: AuthTokenContext | RunTokenContext; message: string; + quoteText?: string; images?: string[]; source?: string; clientMessageId?: string; @@ -787,6 +791,7 @@ export async function sendMessageToTask({ taskId, userId: linkedReviewHandoff.senderUserId, message, + quoteText, images, source, clientMessageId, @@ -829,7 +834,7 @@ export async function sendMessageToTask({ payload: run.payload as Record | null, slackThreadTs: channelBindings?.slackThreadTs ?? null, userId: senderUserId, - message, + message: quoteText, senderMode, }); @@ -861,6 +866,7 @@ export async function sendMessageToTask({ call: (client) => client.commands.sendPrompt.mutate({ prompt: message, + quoteText, ...(followUpPromptSource ? { source: followUpPromptSource } : {}), ...(normalizedClientMessageId ? { clientMessageId: normalizedClientMessageId } @@ -927,6 +933,7 @@ export async function steerMessageToTask({ taskId, userId, message, + quoteText = message, images, senderMode, workerQuoteUserName, @@ -934,6 +941,7 @@ export async function steerMessageToTask({ taskId: string; userId: string; message: string; + quoteText?: string; images?: string[]; senderMode?: SendMessageSenderMode; /** @@ -967,6 +975,7 @@ export async function steerMessageToTask({ taskId, userId, message, + quoteText, images, sourceRun: run as LatestTaskRun, channelBindings, @@ -1000,7 +1009,7 @@ export async function steerMessageToTask({ payload: run.payload as Record | null, slackThreadTs: channelBindings?.slackThreadTs ?? null, userId, - message, + message: quoteText, senderMode, }); @@ -1026,6 +1035,7 @@ export async function steerMessageToTask({ call: (client) => client.commands.steerTask.mutate({ prompt: message, + quoteText, ...(resolvedQuoteUserName ? { userName: resolvedQuoteUserName } : {}), diff --git a/apps/docs/environments.mdx b/apps/docs/environments.mdx index e029c9e53..cffe40df0 100644 --- a/apps/docs/environments.mdx +++ b/apps/docs/environments.mdx @@ -41,6 +41,21 @@ The setup task is meant to produce a working environment Roomote can reuse. If it cannot finish, adjust the input and try again from **Settings > Environments**. +## Start from a brand-new repository + +You do not need an existing codebase to set up an environment. From +**Settings > Environments > New** (or the onboarding repo-selection step), +choose **Create a new repository** to open github.com with the right owner +pre-filled — either a brand-new repository or a fork of an existing one by +URL. Once you create it on GitHub, it appears in the repository list +automatically; if the GitHub App only has access to selected repositories, +grant it access to the new repository first. + +An empty repository is fine. When you start setup against a repository with +no commits, Roomote pushes a minimal initial commit (a README and a +.gitignore) to the default branch and creates a basic environment. Building +the actual project is then just your first task in that environment. + ## What to include Add enough context for Roomote to start productively: diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 47da38a8a..5dcb92acd 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -15,9 +15,26 @@ priority, or discussion. ## Setup - - In Roomote, go to **Settings > Integrations** and connect your Linear - workspace. + + On a self-hosted deployment, an administrator goes to **Settings > + Integrations**, selects **Set it up**, and then selects **Create Linear + app** to open the pre-filled manifest. The app name defaults to the same + `roomote-` convention used by the GitHub setup. After + creating the private app, copy its client ID, client secret, and webhook + secret back into Roomote. Roomote encrypts the saved credentials. + + + Select **Enable Linear**, approve the app in Linear, and return to Roomote. + Deployments that provide `R_LINEAR_CLIENT_ID`, + `R_LINEAR_CLIENT_SECRET`, and `R_LINEAR_WEBHOOK_SECRET` in the runtime + environment continue to use those values and skip the in-app setup. + + + A deployment administrator can return to **Settings > Integrations** and + select **Configure** on Linear. Saving a new client ID or client secret + disconnects the current workspace so it can be authorized again. Removing + saved credentials disables Linear but does not delete the app in Linear. + Credentials managed by the deployment environment must be changed there. Link your Linear identity when prompted so Roomote can associate issue diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index 9fc69c78e..10910d222 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -80,6 +80,7 @@ Grant these repository permissions: - **Code scanning alerts**: Read-only - **Issues**: Read and write - **Metadata**: Read-only +- **Organization projects**: Read and write - **Pull requests**: Read and write - **Workflows**: Read and write @@ -127,6 +128,12 @@ Subscribe to these events: GitHub Apps also receive `installation` and `installation_repositories` events automatically; you do not need to subscribe to them in the app form. +Roomote uses the **Repository** and `installation_repositories` events to +pick up newly created or newly granted repositories without a manual refresh. +If your app was created before the **Repository** event was part of the +manifest, confirm it is enabled under the app's **Permissions & events** page +— GitHub has no API to update an app's event subscriptions. + ## Save credentials Copy these values into the `/setup` manual form, deployment env vars, or your @@ -155,6 +162,34 @@ Install the GitHub App on the repositories Roomote should use. Then sign in, complete the GitHub step in `/setup`, and confirm repository sync sees the expected repositories. +If you add Organization projects permission to an existing app, an organization +owner must approve the updated permission before Roomote can manage project +items. + +## Daily GitHub management + +Roomote tasks can manage routine repository work through the GitHub App +installation. Requests default to the repository mapped to the task: + +- list, create, edit, delete, and apply labels to issues or pull requests +- create, update, delete, and report progress for milestones; assign issues to milestones +- list accessible GitHub Projects V2, inspect an issue or pull request's Status, + and update an existing project item's single-select Status field + +Read-only requests, creations, and additive label actions run directly. Before +editing, replacing, assigning, clearing, moving, or deleting an item, Roomote +shows the proposed change and asks for confirmation. Deletions name the resource +and URL in that confirmation. After a write, Roomote reads the result back and +shares its GitHub URL. + +Projects V2 operations require the **Organization projects** permission above. +If a project or Status field is ambiguous, Roomote asks you to choose one. An +issue or pull request that is not already on a project is reported as such; +Roomote does not add it automatically. + +Native GitHub saved views are not currently supported. Roomote can share an +issue-search URL, but it does not represent that URL as a saved GitHub view. + ## Start work from issues and pull requests Once the app is installed and an environment maps the repository: diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index b56c1c2f1..ca2cdc982 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -40,6 +40,10 @@ const nextConfig: NextConfig = { '/*': [...webEnvFiles, '../docs/**/*'], }, experimental: { + // Next 16 enables Turbopack's persistent dev cache by default. In this + // monorepo its periodic writes and compactions can monopolize a CPU core, + // so keep the cache in memory for the current dev-server session instead. + turbopackFileSystemCacheForDev: false, serverActions: { bodySizeLimit: '20mb', // Increase body size limit for image uploads. }, diff --git a/apps/web/public/elements/about.png b/apps/web/public/elements/about.png new file mode 100644 index 000000000..33ce7693b Binary files /dev/null and b/apps/web/public/elements/about.png differ diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx new file mode 100644 index 000000000..16309b426 --- /dev/null +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx @@ -0,0 +1,294 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import type { OnboardingLinkableProvider } from '@/app/(onboarding)/onboarding/types'; + +let isAdmin = true; +let linkableProviders: OnboardingLinkableProvider[] = []; +let enabledMcpIds: string[] = []; +let linkedMcpIds: string[] = []; +let orgHasLinear = false; +let userHasLinkedLinear = false; + +const { + mockPush, + mockReplace, + mockAuthenticateSlack, + mockAuthenticateGitHub, + mockAuthenticateLinear, +} = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockReplace: vi.fn(), + mockAuthenticateSlack: vi.fn(), + mockAuthenticateGitHub: vi.fn(), + mockAuthenticateLinear: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ isAdmin }), +})); + +vi.mock('@/hooks/mcp-connections', () => ({ + useDeploymentMcpEnablements: () => ({ + data: enabledMcpIds.map((mcpId) => ({ mcpId, enabled: true })), + isPending: false, + }), + useUserMcpConnections: () => ({ + data: linkedMcpIds.map((mcpId) => ({ mcpId, authStatus: 'authenticated' })), + isPending: false, + }), + useConnectMcp: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock('@/hooks/github', () => ({ + useAuthenticateGitHubAccount: () => ({ + isPending: false, + mutate: mockAuthenticateGitHub, + }), +})); + +vi.mock('@/hooks/slack', () => ({ + useAuthenticateSlackAccount: () => ({ + isPending: false, + mutate: mockAuthenticateSlack, + }), +})); + +vi.mock('@/hooks/linear', () => ({ + useAuthenticateLinearAccount: () => ({ + isPending: false, + mutate: mockAuthenticateLinear, + }), +})); + +vi.mock('@/hooks/linked-accounts', () => ({ + useAuthenticateAdoAccount: () => ({ isPending: false, mutate: vi.fn() }), + useAuthenticateBitbucketAccount: () => ({ + isPending: false, + mutate: vi.fn(), + }), + useAuthenticateGiteaAccount: () => ({ isPending: false, mutate: vi.fn() }), + useAuthenticateGitLabAccount: () => ({ isPending: false, mutate: vi.fn() }), + useAuthenticateMicrosoftTeamsAccount: () => ({ + isPending: false, + mutate: vi.fn(), + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + onboarding: { + status: { queryOptions: () => ({ queryKey: ['onboarding'] }) }, + }, + automations: { + onboardingStatus: { queryOptions: () => ({ queryKey: ['automations'] }) }, + }, + }), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: { queryKey: string[] }) => + options.queryKey[0] === 'onboarding' + ? { + data: { + linkableProviders, + orgHasLinear, + userHasLinkedLinear, + }, + isPending: false, + } + : { data: { hasEnabledAutomations: true }, isPending: false }, +})); + +vi.mock('motion/react', async () => { + const { forwardRef } = await import('react'); + + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + motion: { + div: forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { + initial?: unknown; + animate?: unknown; + exit?: unknown; + variants?: unknown; + transition?: unknown; + } + >( + ( + { + initial: _initial, + animate: _animate, + exit: _exit, + variants: _variants, + transition: _transition, + ...props + }, + ref, + ) =>
, + ), + }, + }; +}); + +vi.mock('@/components/settings/McpIcon', () => ({ + McpIcon: ({ name }: { name: string }) => {name}, +})); + +vi.mock('@/components/settings/DiscordLinkAccountStep', () => ({ + DiscordLinkAccountStep: () =>
Discord link flow
, +})); + +vi.mock('@/components/settings/TelegramLinkAccountStep', () => ({ + TelegramLinkAccountStep: () =>
Telegram link flow
, +})); + +import { OnboardingCard } from './OnboardingCard'; + +function dismissCard() { + fireEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0]!); +} + +beforeEach(() => { + isAdmin = true; + linkableProviders = []; + enabledMcpIds = []; + linkedMcpIds = []; + orgHasLinear = false; + userHasLinkedLinear = false; + localStorage.clear(); + vi.clearAllMocks(); +}); + +it('prioritizes communication accounts before source-control accounts', () => { + linkableProviders = [ + { + id: 'github', + category: 'source-control', + label: 'GitHub', + configured: true, + linked: false, + }, + { + id: 'discord', + category: 'communication', + label: 'Discord', + configured: true, + linked: false, + }, + { + id: 'slack', + category: 'communication', + label: 'Slack', + configured: true, + linked: false, + }, + ]; + + render(); + expect(screen.getByText('Link your Slack account')).toBeInTheDocument(); + dismissCard(); + expect(screen.getByText('Link your Discord account')).toBeInTheDocument(); + dismissCard(); + expect(screen.getByText('Link your GitHub account')).toBeInTheDocument(); +}); + +it('does not offer Slack installation, but links an installed Slack account', () => { + linkableProviders = [ + { + id: 'slack', + category: 'communication', + label: 'Slack', + configured: false, + linked: false, + }, + ]; + + render(); + expect( + screen.queryByText(/Chat with Roomote on Slack/), + ).not.toBeInTheDocument(); + expect( + screen.getByText('Enable Notion for your workspace'), + ).toBeInTheDocument(); +}); + +it('uses the requested admin integration setup order', () => { + render(); + + for (const name of [ + 'Notion', + 'Sentry', + 'Linear', + 'Jira', + 'Vercel', + 'Supabase', + 'PostHog', + 'Grafana', + 'Asana', + ]) { + expect( + screen.getByText(`Enable ${name} for your workspace`), + ).toBeInTheDocument(); + dismissCard(); + } +}); + +it('opens the highlighted integration settings for admin setup', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); + expect(mockPush).toHaveBeenCalledWith( + '/settings/integrations?highlight=notion', + ); +}); + +it('does not show workspace setup to non-admins and prompts enabled personal MCP links', () => { + isAdmin = false; + enabledMcpIds = ['notion']; + + render(); + expect(screen.getByText('Link your Notion account')).toBeInTheDocument(); + expect( + screen.queryByText('Enable Notion for your workspace'), + ).not.toBeInTheDocument(); +}); + +it('starts Slack linking directly', () => { + linkableProviders = [ + { + id: 'slack', + category: 'communication', + label: 'Slack', + configured: true, + linked: false, + }, + ]; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + expect(mockAuthenticateSlack).toHaveBeenCalledWith('/', expect.any(Object)); +}); + +it('opens the Discord account-link dialog directly', () => { + linkableProviders = [ + { + id: 'discord', + category: 'communication', + label: 'Discord', + configured: true, + linked: false, + }, + ]; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + expect(screen.getByText('Discord link flow')).toBeInTheDocument(); +}); diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx index 2d65b6e7c..a1c6f41d7 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx @@ -1,16 +1,11 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; +import { AnimatePresence, motion } from 'motion/react'; import { toast } from 'sonner'; -import { - getMcpIntegrationConnectionScope, - isSelfServeMcpIntegration, - isDeploymentScopedMcpIntegration, - MCP_INTEGRATIONS, - PRODUCT_NAME, -} from '@roomote/types'; +import { MCP_INTEGRATIONS } from '@roomote/types'; import { useAuthorizedUser } from '@/hooks/useUser'; import { @@ -18,17 +13,26 @@ import { useUserMcpConnections, useConnectMcp, } from '@/hooks/mcp-connections'; +import { useAuthenticateGitHubAccount } from '@/hooks/github'; +import { useAuthenticateSlackAccount } from '@/hooks/slack'; +import { useAuthenticateLinearAccount } from '@/hooks/linear'; import { - useGitHubInstallations, - useAuthenticateGitHubAccount, -} from '@/hooks/github'; -import { useSlackInstallation, useConnectSlack } from '@/hooks/slack'; -import { useLinearInstallation, useConnectLinear } from '@/hooks/linear'; -import { useGitHubLinkedAccount } from '@/hooks/linked-accounts'; + useAuthenticateAdoAccount, + useAuthenticateBitbucketAccount, + useAuthenticateGiteaAccount, + useAuthenticateGitLabAccount, + useAuthenticateMicrosoftTeamsAccount, +} from '@/hooks/linked-accounts'; import { useTRPC } from '@/trpc/client'; import { SETTINGS_PATHS } from '@/lib/settings'; import { + BrandIcon, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, Github, LinearLogo, Slack, @@ -38,12 +42,61 @@ import { Zap, } from '@/components/system'; import { McpIcon } from '@/components/settings/McpIcon'; +import { DiscordLinkAccountStep } from '@/components/settings/DiscordLinkAccountStep'; +import { TelegramLinkAccountStep } from '@/components/settings/TelegramLinkAccountStep'; const DISMISSED_KEY = 'OnboardingCardsDismissedByOrg'; +const DISMISSED_DEPLOYMENT_KEY = 'deployment'; + +const ADMIN_INTEGRATION_ORDER = [ + 'notion', + 'sentry', + 'linear', + 'jira', + 'vercel', + 'supabase', + 'posthog', + 'grafana', + 'asana', +] as const; + +const PERSONAL_MCP_INTEGRATION_ORDER = ['notion', 'supabase'] as const; + +const CARD_EXIT_TRANSITION = { + duration: 0.4, + ease: 'easeOut', +} as const; + +const CARD_ENTER_TRANSITION = { + duration: 0.4, + delay: 0.25, + ease: 'easeOut', +} as const; + +const CARD_ANIMATION = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0, transition: CARD_ENTER_TRANSITION }, + exit: { opacity: 0, y: -20, transition: CARD_EXIT_TRANSITION }, +} as const; + +const COMMUNICATION_PROVIDER_ORDER = [ + 'slack', + 'microsoft', + 'telegram', + 'discord', +] as const; + +const SOURCE_CONTROL_PROVIDER_ORDER = [ + 'github', + 'gitlab', + 'gitea', + 'bitbucket', + 'ado', +] as const; type CardConfig = { id: string; - icon: React.ReactNode; + icon: ReactNode; label: string; buttonLabel: string; onClick: () => void; @@ -52,24 +105,27 @@ type CardConfig = { visible: boolean; }; -const DISMISSED_DEPLOYMENT_KEY = 'deployment'; +type LinkableProviderId = + | 'slack' + | 'microsoft' + | 'telegram' + | 'discord' + | 'github' + | 'gitlab' + | 'gitea' + | 'bitbucket' + | 'ado'; function readDismissedCardIds(): string[] { try { const raw = localStorage.getItem(DISMISSED_KEY); - if (!raw) { - return []; - } + if (!raw) return []; const parsed = JSON.parse(raw) as Record; const dismissed = parsed[DISMISSED_DEPLOYMENT_KEY]; - if (!Array.isArray(dismissed)) { - return []; - } - - return dismissed.filter( - (value): value is string => typeof value === 'string', - ); + return Array.isArray(dismissed) + ? dismissed.filter((value): value is string => typeof value === 'string') + : []; } catch { return []; } @@ -79,7 +135,6 @@ function writeDismissedCardIds(ids: string[]): void { try { const raw = localStorage.getItem(DISMISSED_KEY); const parsed = raw ? (JSON.parse(raw) as Record) : {}; - parsed[DISMISSED_DEPLOYMENT_KEY] = ids; localStorage.setItem(DISMISSED_KEY, JSON.stringify(parsed)); } catch { @@ -87,9 +142,13 @@ function writeDismissedCardIds(ids: string[]): void { } } -/** - * Shows at most one onboarding guidance card at a time, in priority order. - */ +function getMcpIntegration(id: string) { + const integration = MCP_INTEGRATIONS.find((entry) => entry.id === id); + if (!integration) throw new Error(`Unknown MCP integration: ${id}`); + return integration; +} + +/** Shows at most one onboarding guidance card at a time, in priority order. */ export function OnboardingCard() { const { isAdmin } = useAuthorizedUser(); const searchParams = useSearchParams(); @@ -97,114 +156,44 @@ export function OnboardingCard() { const trpc = useTRPC(); const shouldShowSuggestedTasksCard = searchParams.get('link_suggested') === 'true'; - - const { data: githubInstallations = [], isPending: githubPending } = - useGitHubInstallations(); - const { data: slackInstallation, isPending: slackPending } = - useSlackInstallation(); - const { data: linearInstallation, isPending: linearPending } = - useLinearInstallation(); - const { data: githubLinkedAccount, isPending: githubAccountPending } = - useGitHubLinkedAccount(); + const onboarding = useQuery(trpc.onboarding.status.queryOptions()); const enablements = useDeploymentMcpEnablements(); const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); - const mcpPending = enablements.isPending || userMcpConnections.isPending; const { data: automationOnboardingStatus, isPending: automationsPending } = useQuery(trpc.automations.onboardingStatus.queryOptions()); - const promotedMcpIntegrations = mcpPending - ? [] - : MCP_INTEGRATIONS.filter((integration) => - isSelfServeMcpIntegration(integration), - ) - .map((integration, index) => ({ integration, index })) - .filter(({ integration }) => Boolean(integration.homepageCard)) - .filter(({ integration }) => - isDeploymentScopedMcpIntegration(integration) - ? isAdmin - : (enablements.data ?? []).some( - (entry) => entry.mcpId === integration.id && entry.enabled, - ), - ) - .filter( - ({ integration }) => - !isDeploymentScopedMcpIntegration(integration) || isAdmin, - ) - .filter( - ({ integration }) => - !(userMcpConnections.data ?? []).some( - (connection) => - connection.mcpId === integration.id && - connection.authStatus === 'authenticated', - ), - ) - .sort((left, right) => { - const leftPriority = left.integration.homepageCard?.priority ?? 0; - const rightPriority = right.integration.homepageCard?.priority ?? 0; - - if (leftPriority === rightPriority) { - return left.index - right.index; - } - - return rightPriority - leftPriority; - }) - .map(({ integration }) => integration); - + const authenticateSlackAccount = useAuthenticateSlackAccount(); + const authenticateGitHubAccount = useAuthenticateGitHubAccount(); + const authenticateLinearAccount = useAuthenticateLinearAccount(); + const authenticateMicrosoftTeamsAccount = + useAuthenticateMicrosoftTeamsAccount(); + const authenticateGitLabAccount = useAuthenticateGitLabAccount(); + const authenticateGiteaAccount = useAuthenticateGiteaAccount(); + const authenticateBitbucketAccount = useAuthenticateBitbucketAccount(); + const authenticateAdoAccount = useAuthenticateAdoAccount(); + const [linkDialog, setLinkDialog] = useState<'telegram' | 'discord' | null>( + null, + ); const [dismissed, setDismissed] = useState>({}); - - const connectSlack = useConnectSlack('/', { - onError: () => { - toast.error('Failed to connect Slack. Please try again.'); - }, - }); - - const connectLinear = useConnectLinear('/', { - onError: () => { - toast.error('Failed to connect Linear. Please try again.'); - }, - }); - - const authenticateGitHubAccount = useAuthenticateGitHubAccount({ - onSuccess: (result) => { - if (result.success) { - window.location.href = result.url; - } else { - toast.error(result.error); - } - }, - onError: () => - toast.error('Failed to link GitHub account. Please try again.'), - }); - const connectionToastedRef = useRef(false); useEffect(() => { - if (connectionToastedRef.current) { - return; - } + if (connectionToastedRef.current) return; const slackConnected = searchParams.get('slack') === 'connected'; const linearConnected = searchParams.get('linear') === 'connected'; + if (!slackConnected && !linearConnected) return; - if (slackConnected || linearConnected) { - connectionToastedRef.current = true; - - if (slackConnected) { - toast.success('Slack connected successfully'); - } - - if (linearConnected) { - toast.success('Linear connected successfully'); - } + connectionToastedRef.current = true; + if (slackConnected) toast.success('Slack account linked successfully'); + if (linearConnected) toast.success('Linear account linked successfully'); - // Remove the query params from the URL without a full navigation - const url = new URL(window.location.href); - url.searchParams.delete('slack'); - url.searchParams.delete('linear'); - router.replace(url.pathname + url.search, { scroll: false }); - } - }, [searchParams, router]); + const url = new URL(window.location.href); + url.searchParams.delete('slack'); + url.searchParams.delete('linear'); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router, searchParams]); useEffect(() => { const dismissedIds = readDismissedCardIds(); @@ -214,18 +203,224 @@ export function OnboardingCard() { }, []); const dismiss = (cardId: string) => { - const nextDismissed = { - ...dismissed, - [cardId]: true, - }; - + const nextDismissed = { ...dismissed, [cardId]: true }; setDismissed(nextDismissed); + writeDismissedCardIds( + Object.entries(nextDismissed) + .filter(([, isDismissed]) => isDismissed) + .map(([id]) => id), + ); + }; - const nextDismissedIds = Object.entries(nextDismissed) - .filter(([, isDismissed]) => isDismissed) - .map(([id]) => id); + const startOAuthLink = ( + name: string, + mutation: { + mutate: (redirect: string, options?: { onError: () => void }) => void; + }, + ) => { + mutation.mutate('/', { + onError: () => toast.error(`Failed to link ${name}. Please try again.`), + }); + }; + + const linkProvider = (providerId: LinkableProviderId) => { + switch (providerId) { + case 'slack': + authenticateSlackAccount.mutate('/', { + onSuccess: (result) => { + if (result.success) window.location.href = result.url; + else toast.error(result.error); + }, + onError: () => toast.error('Failed to link Slack. Please try again.'), + }); + return; + case 'github': + authenticateGitHubAccount.mutate( + { redirect: '/', callbackBackground: 'background' }, + { + onSuccess: (result) => { + if (result.success) window.location.href = result.url; + else toast.error(result.error); + }, + onError: () => + toast.error('Failed to link GitHub. Please try again.'), + }, + ); + return; + case 'microsoft': + startOAuthLink('Microsoft Teams', authenticateMicrosoftTeamsAccount); + return; + case 'gitlab': + startOAuthLink('GitLab', authenticateGitLabAccount); + return; + case 'gitea': + startOAuthLink('Gitea', authenticateGiteaAccount); + return; + case 'bitbucket': + startOAuthLink('Bitbucket Cloud', authenticateBitbucketAccount); + return; + case 'ado': + startOAuthLink('Azure DevOps', authenticateAdoAccount); + return; + case 'telegram': + case 'discord': + setLinkDialog(providerId); + } + }; - writeDismissedCardIds(nextDismissedIds); + const isProviderLinkPending = (providerId: LinkableProviderId) => { + switch (providerId) { + case 'slack': + return authenticateSlackAccount.isPending; + case 'github': + return authenticateGitHubAccount.isPending; + case 'microsoft': + return authenticateMicrosoftTeamsAccount.isPending; + case 'gitlab': + return authenticateGitLabAccount.isPending; + case 'gitea': + return authenticateGiteaAccount.isPending; + case 'bitbucket': + return authenticateBitbucketAccount.isPending; + case 'ado': + return authenticateAdoAccount.isPending; + default: + return false; + } + }; + + const status = onboarding.data; + const isIntegrationsPending = + onboarding.isPending || + enablements.isPending || + userMcpConnections.isPending; + const enabledMcpIds = new Set( + (enablements.data ?? []) + .filter((entry) => entry.enabled) + .map((entry) => entry.mcpId), + ); + const authenticatedMcpIds = new Set( + (userMcpConnections.data ?? []) + .filter((connection) => connection.authStatus === 'authenticated') + .map((connection) => connection.mcpId), + ); + + const providerIcon = (providerId: LinkableProviderId) => { + switch (providerId) { + case 'slack': + return ( + + ); + case 'github': + return ( + + ); + case 'microsoft': + return ( + + ); + default: { + const icon = providerId === 'ado' ? 'ado' : providerId; + const name = + providerId === 'bitbucket' ? 'Bitbucket Cloud' : providerId; + return ; + } + } + }; + + const providerCards: CardConfig[] = (status?.linkableProviders ?? []).map( + (provider) => ({ + id: `link-${provider.id}`, + icon: providerIcon(provider.id as LinkableProviderId), + label: `Link your ${provider.label} account`, + buttonLabel: 'Link', + onClick: () => linkProvider(provider.id as LinkableProviderId), + disabled: isProviderLinkPending(provider.id as LinkableProviderId), + visible: + !isIntegrationsPending && provider.configured && !provider.linked, + }), + ); + + const adminIntegrationCards: CardConfig[] = ADMIN_INTEGRATION_ORDER.map( + (integrationId) => { + const integration = getMcpIntegration(integrationId); + const enabled = + integrationId === 'linear' + ? Boolean(status?.orgHasLinear) + : enabledMcpIds.has(integrationId); + const settingsId = + integrationId === 'sentry' ? 'sentry-mcp' : integrationId; + return { + id: `enable-${integrationId}`, + icon: + integrationId === 'linear' ? ( + + ) : ( + + ), + label: `Enable ${integration.name} for your workspace`, + buttonLabel: 'Set it up', + onClick: () => + router.push(`${SETTINGS_PATHS.integrations}?highlight=${settingsId}`), + visible: isAdmin && !isIntegrationsPending && !enabled, + }; + }, + ); + + const personalMcpCards: CardConfig[] = PERSONAL_MCP_INTEGRATION_ORDER.map( + (integrationId) => { + const integration = getMcpIntegration(integrationId); + const isConnected = authenticatedMcpIds.has(integrationId); + const isPending = + connectMcp.isPending && connectMcp.variables?.mcpId === integrationId; + return { + id: `link-${integrationId}`, + icon: , + label: `Link your ${integration.name} account`, + buttonLabel: 'Link', + onClick: () => { + connectMcp.mutate( + { mcpId: integrationId, redirectTo: '/' }, + { + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => + toast.error( + `Failed to link ${integration.name}. Please try again.`, + ), + }, + ); + }, + disabled: isPending, + visible: + !isIntegrationsPending && + enabledMcpIds.has(integrationId) && + !isConnected, + }; + }, + ); + + const linearPersonalCard: CardConfig = { + id: 'link-linear', + icon: , + label: 'Link your Linear account', + buttonLabel: 'Link', + onClick: () => + authenticateLinearAccount.mutate('/', { + onError: () => toast.error('Failed to link Linear. Please try again.'), + }), + disabled: authenticateLinearAccount.isPending, + visible: + !isIntegrationsPending && + Boolean(status?.orgHasLinear) && + !status?.userHasLinkedLinear, }; const cards: CardConfig[] = [ @@ -234,196 +429,124 @@ export function OnboardingCard() { icon: , label: 'Your selected tasks have started.', buttonLabel: 'Check their progress', - onClick: () => { - router.push('/tasks'); - }, + onClick: () => router.push('/tasks'), dismissible: false, visible: shouldShowSuggestedTasksCard, }, + ...COMMUNICATION_PROVIDER_ORDER.flatMap((providerId) => + providerCards.filter((card) => card.id === `link-${providerId}`), + ), + ...SOURCE_CONTROL_PROVIDER_ORDER.flatMap((providerId) => + providerCards.filter((card) => card.id === `link-${providerId}`), + ), + ...adminIntegrationCards, + ...personalMcpCards, + linearPersonalCard, { - id: 'slack', - icon: ( - - ), - label: 'Chat with Roomote on Slack', - buttonLabel: 'Do it', - onClick: () => { - void (async () => { - try { - const url = await connectSlack.mutateAsync(); - - if (url) { - window.location.href = url; - } - } catch { - // Error handled by onError. - } - })(); - }, - disabled: connectSlack.isPending, - visible: !slackPending && !slackInstallation, - }, - { - id: 'linear', - icon: ( - - ), - label: 'Assign tasks to agents from Linear', - buttonLabel: 'Do it', - onClick: () => { - void (async () => { - try { - const url = await connectLinear.mutateAsync(); - - if (url) { - window.location.href = url; - } - } catch { - // Error handled by onError. - } - })(); - }, - disabled: connectLinear.isPending, - visible: !linearPending && !linearInstallation, - }, - { - id: 'github-account', + id: 'automations', icon: ( - ), - label: `Link your GitHub so ${PRODUCT_NAME} acts as you`, - buttonLabel: 'Do it', - onClick: () => { - void (async () => { - try { - const result = await authenticateGitHubAccount.mutateAsync({ - redirect: '/', - callbackBackground: 'background', - }); - - if (result.success) { - window.location.href = result.url; - } - } catch { - // Error handled by onError. - } - })(); - }, - disabled: authenticateGitHubAccount.isPending, + label: 'Automations keep your repos moving in the background', + buttonLabel: 'Set them up', + onClick: () => router.push(SETTINGS_PATHS.automations), visible: - !githubPending && - !githubAccountPending && - githubInstallations.length > 0 && - !githubLinkedAccount, + !automationsPending && + Boolean(automationOnboardingStatus) && + !automationOnboardingStatus?.hasEnabledAutomations, }, ]; - cards.push( - ...promotedMcpIntegrations.map((integration) => ({ - id: `${integration.id}-connect`, - icon: , - label: - integration.homepageCard?.label ?? - (getMcpIntegrationConnectionScope(integration) === 'deployment' - ? `Connect ${integration.name} so Roomote can access it` - : `Connect ${integration.name} so Roomote can access it`), - buttonLabel: integration.homepageCard?.buttonLabel ?? 'Connect', - onClick: () => { - connectMcp.mutate( - { mcpId: integration.id, redirectTo: '/' }, - { - onSuccess: (url) => { - window.location.href = url; - }, - onError: () => { - toast.error( - `Failed to connect ${integration.name}. Please try again.`, - ); - }, - }, - ); - }, - disabled: connectMcp.isPending, - visible: true, - })), - ); - - cards.push({ - id: 'automations', - icon: ( - - ), - label: 'Automations keep your repos moving in the background', - buttonLabel: 'Set them up', - onClick: () => { - router.push(SETTINGS_PATHS.automations); - }, - visible: - !automationsPending && - Boolean(automationOnboardingStatus) && - !automationOnboardingStatus?.hasEnabledAutomations, - }); - const activeCard = cards.find((card) => card.visible && !dismissed[card.id]); - - if (!activeCard) { - return null; - } + if (!activeCard) return null; return ( <> -
- {activeCard.icon} -
- - - {activeCard.label} - - {activeCard.dismissible !== false && ( +
+ + + {activeCard.icon} +
+ + + {activeCard.label} + + {activeCard.dismissible !== false && ( + + )} + - )} - - - {activeCard.dismissible !== false && ( - - )} -
+ {activeCard.dismissible !== false && ( + + )} +
+ +
+ !open && setLinkDialog(null)} + > + + + Link your Telegram account + + Connect your Telegram identity to Roomote. + + + + + + !open && setLinkDialog(null)} + > + + + Link your Discord account + + Connect your Discord identity to Roomote. + + + + + ); } diff --git a/apps/web/src/app/(onboarding)/setup/NumberedStep.tsx b/apps/web/src/app/(onboarding)/setup/NumberedStep.tsx index 31263825e..96c903637 100644 --- a/apps/web/src/app/(onboarding)/setup/NumberedStep.tsx +++ b/apps/web/src/app/(onboarding)/setup/NumberedStep.tsx @@ -10,10 +10,14 @@ export function NumberedStep({ className?: string; }) { return ( -
- - {number} - +
= 0 && 'flex gap-2 items-start'} ${className ?? ''}`} + > + {number >= 0 && ( + + {number} + + )}
{children}
); diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx index a53fff7ed..fe01a8c8e 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx @@ -53,6 +53,57 @@ const TELEGRAM_SETUP_HIDDEN_ENV_VAR_NAMES = new Set([ 'R_TELEGRAM_WEBHOOK_SECRET', ]); +export const MICROSOFT_APP_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const MICROSOFT_APP_ID_FIELD_ENV_VAR_NAMES = new Set([ + 'R_MICROSOFT_CLIENT_ID', + 'R_TEAMS_BOT_APP_ID', +]); + +/** + * Inline warning shown directly under an app-id input whose value cannot be an + * Entra client id. The package-download note further down explains the same + * problem, but the feedback belongs at the field where the value was typed. + */ +function getMicrosoftAppIdFormatWarning( + providerId: string, + envVarName: string, + value: string, +): string | null { + if ( + providerId !== 'microsoft' || + !MICROSOFT_APP_ID_FIELD_ENV_VAR_NAMES.has(envVarName) + ) { + return null; + } + + const trimmed = value.trim(); + + if (!trimmed || MICROSOFT_APP_ID_PATTERN.test(trimmed)) { + return null; + } + + return "This doesn't look like an Entra app ID — expected a GUID like 00000000-0000-0000-0000-000000000000."; +} + +/** + * The Teams app package embeds the bot app id, so a value that is not a GUID + * cannot produce a usable package. Explain that instead of leaving the + * download button dead with no reason. + */ +export function getTeamsAppPackageUnavailableReason( + enteredAppId: string, +): string | null { + const appId = enteredAppId.trim(); + + if (!appId || MICROSOFT_APP_ID_PATTERN.test(appId)) { + return null; + } + + return 'That App (Client) ID is not a valid GUID, so Roomote cannot pre-fill the Teams app package. Copy the Application (client) ID from the Entra app registration overview — it looks like 00000000-0000-0000-0000-000000000000.'; +} + export function getSetupVisibleFields( provider: ProviderStatus | null, options: { showMicrosoftAdvancedConfig?: boolean } = {}, @@ -223,6 +274,14 @@ function ProviderFields({ explicitValue.length === 0 && !clearedSavedValues[field.envVarName] && !editingSavedValues[field.envVarName]; + const formatWarning = + !isSecretField && !field.runtimeSatisfied + ? getMicrosoftAppIdFormatWarning( + provider.id, + field.envVarName, + value, + ) + : null; return (
{(field.runtimeSatisfied || field.savedSatisfied) && }
+ {formatWarning ? ( +

{formatWarning}

+ ) : null}
); @@ -292,6 +354,12 @@ type ProviderSetupExperienceProps = { editingSavedValues: Record; clearedSavedValues: Record; teamsAppPackageHref: string | null; + /** + * Why the pre-filled package cannot be built yet (e.g. the entered App + * (Client) ID is not a GUID). Without it the download button just goes dead + * with nothing to act on. + */ + teamsAppPackageUnavailableReason?: string | null; createdSlackAppSettingsUrl?: string | null; createdSlackAppIconSet?: boolean | null; createSlackAppPending?: boolean; @@ -687,11 +755,17 @@ function MicrosoftSetupExperience(props: ProviderSetupExperienceProps) {
) : ( -
- +
+
+ +
+

+ {props.teamsAppPackageUnavailableReason ?? + 'Enter the values above to build a pre-filled package.'} +

)}
diff --git a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx index d7925daa9..fde3f72cb 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx @@ -17,12 +17,11 @@ import { getSetupEffectiveFieldValue, getSetupSubmitValues, getSetupVisibleFields, + getTeamsAppPackageUnavailableReason, + MICROSOFT_APP_ID_PATTERN, ProviderSetupExperience, } from './ProviderSetupExperience'; -/** Microsoft app (client) IDs are GUIDs. */ -const MICROSOFT_APP_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const BOOTSTRAP_SIGN_IN_CALLBACK_PATH = '/setup'; function getOAuth2ProviderId( @@ -270,6 +269,9 @@ export function StepAuthEnvVars({ : !bootstrapMode && teamsBotAppIdStored ? '/api/teams/app-package' : null; + const teamsAppPackageUnavailableReason = isMicrosoftProvider + ? getTeamsAppPackageUnavailableReason(enteredTeamsBotAppId) + : null; useEffect(() => { setCreatedSlackAppSettingsUrl(null); setCreatedSlackAppIconSet(null); @@ -336,6 +338,7 @@ export function StepAuthEnvVars({ editingSavedValues={editingSavedValues} clearedSavedValues={clearedSavedValues} teamsAppPackageHref={teamsAppPackageHref} + teamsAppPackageUnavailableReason={teamsAppPackageUnavailableReason} createdSlackAppSettingsUrl={createdSlackAppSettingsUrl} createdSlackAppIconSet={createdSlackAppIconSet} createSlackAppPending={ diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx index 6e69d4ee1..b050d0842 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx @@ -112,6 +112,34 @@ vi.mock('@/hooks/source-control', () => ({ useRepositories: mockUseRepositories, })); +vi.mock('@/components/github/CreateGitHubRepoDialog', () => ({ + CreateGitHubRepoDialog: ({ + open, + onRepositoryDetected, + }: { + open: boolean; + onRepositoryDetected?: (repository: { + id: string; + fullName: string; + isEmpty?: boolean; + }) => void; + }) => + open ? ( + + ) : null, +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { @@ -218,6 +246,7 @@ vi.mock('@/components/system', () => ({ Input: (props: InputHTMLAttributes) => , ArrowRight: (props: SVGProps) => , Loader2: (props: SVGProps) => , + Plus: (props: SVGProps) => , RefreshCcw: (props: SVGProps) => , RotateCw: (props: SVGProps) => , Search: (props: SVGProps) => , @@ -633,7 +662,7 @@ describe('StepRepoSelection', () => { expect(onReviewComputeProvider).toHaveBeenCalled(); }); - it('shows a warning and disables Continue only when all selected repositories are empty', async () => { + it('explains the bootstrap and keeps Continue enabled when all selected repositories are empty', async () => { mockRepositories.splice( 0, mockRepositories.length, @@ -661,9 +690,11 @@ describe('StepRepoSelection', () => { screen.getByText(/all selected repositories have no commits yet/i), ).toBeInTheDocument(); expect( - screen.getByText(/push an initial commit before continuing/i), + screen.getByText( + /will push an initial commit and set up a basic environment/i, + ), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); }); it('keeps Continue enabled and hides the warning for mixed empty and non-empty selections', async () => { @@ -692,11 +723,58 @@ describe('StepRepoSelection', () => { fireEvent.click(screen.getByLabelText(/acme\/empty/i)); expect( - screen.queryByText(/push an initial commit before continuing/i), + screen.queryByText( + /will push an initial commit and set up a basic environment/i, + ), ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); }); + it('offers the create-repo affordance and selects the repository it detects', async () => { + mockRepositories.splice(0, mockRepositories.length, { + id: 'repo-created', + fullName: 'acme/created', + private: true, + defaultBranch: 'main', + isEmpty: true, + }); + + await renderStepRepoSelection(); + + fireEvent.click( + screen.getByRole('button', { name: 'Create a new repository' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Detect created repository' }), + ); + + expect(screen.getByLabelText('acme/created')).toBeChecked(); + }); + + it('keeps the create-repository item visible when the list is filtered to no matches', async () => { + mockRepositories.push( + ...Array.from({ length: 4 }, (_, index) => ({ + id: `repo-extra-${index}`, + fullName: `acme/extra-${index}`, + private: true, + defaultBranch: 'main', + })), + ); + + await renderStepRepoSelection(); + + fireEvent.change(screen.getByLabelText('Filter repositories'), { + target: { value: 'does-not-exist' }, + }); + + expect( + screen.getByRole('button', { name: 'Create a new repository' }), + ).toBeInTheDocument(); + expect( + screen.getByText('No repositories match that filter.'), + ).toBeInTheDocument(); + }); + it('renders the empty-repository warning below the repository list', async () => { mockRepositories.splice( 0, diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx index 7a38e4571..15a7acb55 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx @@ -39,6 +39,10 @@ import { Info, X, } from '@/components/system'; +import { + CreateGitHubRepoDialog, + type DetectedRepository, +} from '@/components/github/CreateGitHubRepoDialog'; import { EnvironmentRepositorySelector } from '@/components/settings/environments/EnvironmentRepositorySelector'; import { ModelSelect } from '@/components/tasks'; import { SetupFooter } from './SetupFooter'; @@ -106,6 +110,7 @@ export function StepRepoSelection({ initialSelectedModelId ?? undefined, ); const [repositoryFilter, setRepositoryFilter] = useState(''); + const [createRepoDialogOpen, setCreateRepoDialogOpen] = useState(false); const [setupGuidance, setSetupGuidance] = useState(initialSetupGuidance); const [isRefreshPending, setIsRefreshPending] = useState(false); const refreshPromiseRef = useRef | null>(null); @@ -205,6 +210,17 @@ export function StepRepoSelection({ ); }, []); + const selectDetectedRepository = useCallback( + (repository: DetectedRepository) => { + setSelectedRepositoryIds((currentSelection) => + currentSelection.includes(repository.id) + ? currentSelection + : [...currentSelection, repository.id], + ); + }, + [], + ); + const handleManageGitHubAccess = useCallback(() => { connectGitHub.mutate(`${pathname}?step=repo-selection`); }, [connectGitHub, pathname]); @@ -282,57 +298,72 @@ export function StepRepoSelection({ if (sortedRepositories.length === 0) { return ( -
-
- -
-
-

No repositories available yet.

-

- {PRODUCT_NAME} is connected to GitHub, but this deployment does not - currently have any accessible repositories to use for setup. -

-
- - - + <> +
+
+ +
+
+

No repositories available yet.

+

+ {PRODUCT_NAME} is connected to GitHub, but this deployment does + not currently have any accessible repositories to use for setup. +

+ setCreateRepoDialogOpen(true)} + inputPrefix="setup-repository" + heightClassName="h-auto" + /> +
+ + + +
+

Not recommended

-

Not recommended

-
+ + ); } @@ -349,245 +380,257 @@ export function StepRepoSelection({ .join(', '); return ( -
-
- -
-
- {!showForm && ( -
- -

- - {PRODUCT_NAME} needs environments to verify its work. - -
- That lets it run your app locally, click around, make API calls, - take screenshots. -

-
- )} -

- Pick the repo(s) needed for the first env to set up. -
- Roomote will install dependencies and figure it all out on its own. -

-
+ <> +
+
+ +
+
+ {!showForm && ( +
+ +

+ + {PRODUCT_NAME} needs environments to verify its work. + +
+ That lets it run your app locally, click around, make API calls, + take screenshots. +

+
+ )} +

+ Pick the repo(s) needed for the first env to set up. +
+ Roomote will install dependencies and figure it all out on its own. +

+
- {retryCopy ? ( - - - - {/* Single child: AlertDescription lays out its children with + {retryCopy ? ( + + + + {/* Single child: AlertDescription lays out its children with flex, which would split loose text and the button apart. */} -

- {retryCopy} - {retryReason === 'task-failed' && onReviewComputeProvider ? ( - <> - {' '} - If the run failed before doing any work, you can also{' '} - - . - - ) : null} -

-
-
- ) : null} - - {computeProvisioningError ? ( - - - -

- Sandbox provider provisioning failed: {computeProvisioningError}{' '} - {onRetryComputeProvisioning ? ( - - ) : null} -

-
-
- ) : null} - - - -
- {showRepositoryFilter ? ( -
- - - setRepositoryFilter(event.currentTarget.value) - } - placeholder="Filter repositories" - aria-label="Filter repositories" - className="pr-9 pl-9" - /> - {repositoryFilter ? ( +

+ {retryCopy} + {retryReason === 'task-failed' && onReviewComputeProvider ? ( + <> + {' '} + If the run failed before doing any work, you can also{' '} + + . + + ) : null} +

+ + + ) : null} + + {computeProvisioningError ? ( + + + +

+ Sandbox provider provisioning failed: {computeProvisioningError}{' '} + {onRetryComputeProvisioning ? ( ) : null} -

- ) : null} +

+ + + ) : null} + + + +
+ {showRepositoryFilter ? ( +
+ + + setRepositoryFilter(event.currentTarget.value) + } + placeholder="Filter repositories" + aria-label="Filter repositories" + className="pr-9 pl-9" + /> + {repositoryFilter ? ( + + ) : null} +
+ ) : null} - {filteredRepositories.length > 0 ? ( setCreateRepoDialogOpen(true)} inputPrefix="setup-repository" heightClassName="max-h-[calc(var(--effective-viewport-height)-40rem)] md:h-[18.75rem]" /> - ) : ( -
- No repositories match that filter. -
- )} -
- {allSelectedRepositoriesAreEmpty ? ( - - - -

- {emptyRepositoryWarningCopy} Push an initial commit before - continuing, or choose different repositories. -

-

- {selectedEmptyRepositoryNames} -

- -
-
- ) : null} - {showForm ? ( -
-