diff --git a/apps/api/src/handlers/github/__tests__/isMention.test.ts b/apps/api/src/handlers/github/__tests__/isMention.test.ts index 91de0fc1..58db5c5a 100644 --- a/apps/api/src/handlers/github/__tests__/isMention.test.ts +++ b/apps/api/src/handlers/github/__tests__/isMention.test.ts @@ -11,6 +11,7 @@ vi.mock('@roomote/env', async (importOriginal) => { }; }); +import { Env } from '@roomote/env'; import { setConfiguredGitHubAppSlugCache } from '@roomote/github'; import { isMention } from '../isMention'; @@ -136,12 +137,21 @@ describe('isMention', () => { ).toBe(true); }); - it('ignores mentions of the default slug when another slug is configured', () => { + it('still detects the canonical @roomote alias', () => { expect( isMention({ body: 'Hey @roomote can you take a look?', user: { login: 'testuser' }, }), + ).toBe(true); + }); + + it('applies word boundaries to the canonical alias too', () => { + expect( + isMention({ + body: 'cc @roomote-fan on this one', + user: { login: 'testuser' }, + }), ).toBe(false); }); @@ -154,4 +164,39 @@ describe('isMention', () => { ).toBe(false); }); }); + + describe('with the canonical alias disabled', () => { + const mutableEnv = Env as unknown as Record; + + beforeEach(() => { + mutableEnv.R_GITHUB_DISABLE_CANONICAL_MENTION = 'true'; + setConfiguredGitHubAppSlugCache({ + value: 'acme', + expiresAt: Date.now() + 60_000, + }); + }); + + afterEach(() => { + delete mutableEnv.R_GITHUB_DISABLE_CANONICAL_MENTION; + setConfiguredGitHubAppSlugCache(null); + }); + + it('ignores the canonical alias', () => { + expect( + isMention({ + body: 'Hey @roomote can you take a look?', + user: { login: 'testuser' }, + }), + ).toBe(false); + }); + + it('still detects the configured slug', () => { + expect( + isMention({ + body: 'Hey @acme can you take a look?', + user: { login: 'testuser' }, + }), + ).toBe(true); + }); + }); }); diff --git a/apps/api/src/handlers/github/isMention.ts b/apps/api/src/handlers/github/isMention.ts index e04c5a4f..03608f49 100644 --- a/apps/api/src/handlers/github/isMention.ts +++ b/apps/api/src/handlers/github/isMention.ts @@ -1,7 +1,9 @@ import { getEffectiveGitHubAppSlug, + isCanonicalGitHubMentionEnabled, Schemas as GitHubSchemas, } from '@roomote/github'; +import { ROOMOTE_CANONICAL_GITHUB_MENTION } from '@roomote/types'; const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -14,11 +16,16 @@ export const isMention = (comment: { return false; } - // GitHub only treats `@name` as a mention when it stands alone; a bare - // substring check would also fire on longer logins (`@-fan`) and on - // email addresses (`grace@.example.com`). + // Deployments answer both their configured app slug and the canonical + // `@roomote` alias that product copy advertises. GitHub only treats `@name` + // as a mention when it stands alone; a bare substring check would also fire + // on longer logins (`@-fan`) and emails (`grace@.example.com`). + const handles = new Set([getEffectiveGitHubAppSlug().toLowerCase()]); + if (isCanonicalGitHubMentionEnabled()) { + handles.add(ROOMOTE_CANONICAL_GITHUB_MENTION.slice(1)); + } const mentionPattern = new RegExp( - `(^|[^\\w.-])@${escapeRegExp(getEffectiveGitHubAppSlug())}(?![\\w-])`, + `(^|[^\\w.-])@(?:${[...handles].map(escapeRegExp).join('|')})(?![\\w-])`, 'i', ); diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index 43e4552d..73ffeff8 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -129,6 +129,13 @@ R_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRI `R_GITHUB_APP_SLUG` is required when your GitHub App slug is not `roomote`; Roomote uses it for GitHub mentions and bot-authored PR detection. +Comments trigger Roomote when they mention either your app's own slug or the +canonical `@roomote` alias, and Roomote's PR footers always advertise +`@roomote`, so the copy your users see works on every deployment. If several +Roomote deployments are installed on the same repositories, set +`R_GITHUB_DISABLE_CANONICAL_MENTION=true` on all but one so a bare `@roomote` +does not make every deployment respond; opted-out deployments answer and +advertise only their own app slug. Generate a private key from the app's **Private keys** section. GitHub downloads a `.pem` file. Convert it to an escaped single-line value before diff --git a/apps/worker/src/mcp/roomote-mcp-server/about-me.ts b/apps/worker/src/mcp/roomote-mcp-server/about-me.ts index 74bac4ca..d7e1ae07 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/about-me.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/about-me.ts @@ -28,7 +28,7 @@ If your org has Code Reviewer enabled (it starts disabled by default) and the PR Linear: Start a task by creating an Agent Session or mentioning me in an issue comment. Same routing and confirmation flow as Slack. Follow-up comments continue the conversation. -GitHub: If your org has the GitHub integration and Code Reviewer enabled (it starts disabled by default), you can @mention me in PR comments to ask for follow-up work on that PR or for another review pass. Those mentions are explicit requests, separate from the automatic review gate that decides which PRs get proactively reviewed. For follow-ups, I push commits directly to the PR branch. Opening a PR or pushing new commits can also trigger an automated code review that posts inline comments and a summary when the PR matches the current review gate. +GitHub: If your org has the GitHub integration and Code Reviewer enabled (it starts disabled by default), you can @mention me (@roomote) in PR comments to ask for follow-up work on that PR or for another review pass. Those mentions are explicit requests, separate from the automatic review gate that decides which PRs get proactively reviewed. For follow-ups, I push commits directly to the PR branch. Opening a PR or pushing new commits can also trigger an automated code review that posts inline comments and a summary when the PR matches the current review gate. Web Dashboard: You can launch tasks from the dashboard by typing a prompt and selecting a workspace. This is also where you configure environments, integrations, and monitor running tasks. diff --git a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts index 68a4b77e..b74704ca 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts @@ -11,6 +11,7 @@ vi.mock('@roomote/env', async (importOriginal) => { }; }); +import { Env } from '@roomote/env'; import { setConfiguredGitHubAppSlugCache, type Schemas } from '@roomote/github'; import { DEFAULT_ROOMOTE_COMMIT_AUTHOR } from '../../commit-author'; @@ -60,16 +61,34 @@ describe('findReusableReviewSummaryComment', () => { }); describe('getPrBodyAttributionLine', () => { - it('mentions the process-env app slug by default', () => { + it('always advertises the canonical @roomote mention', () => { const line = getPrBodyAttributionLine({ attribution: DEFAULT_ROOMOTE_COMMIT_AUTHOR, taskUrl: 'https://app.roomote.dev/tasks/123', }); - expect(line).toContain('@octomote'); + expect(line).toContain('@roomote'); + expect(line).not.toContain('@octomote'); + }); + + it('advertises the deployment slug when the canonical alias is disabled', () => { + const mutableEnv = Env as unknown as Record; + mutableEnv.R_GITHUB_DISABLE_CANONICAL_MENTION = 'true'; + + try { + const line = getPrBodyAttributionLine({ + attribution: DEFAULT_ROOMOTE_COMMIT_AUTHOR, + taskUrl: 'https://app.roomote.dev/tasks/123', + }); + + expect(line).toContain('@octomote'); + expect(line).not.toContain('@roomote '); + } finally { + delete mutableEnv.R_GITHUB_DISABLE_CANONICAL_MENTION; + } }); - it('mentions the database-configured app slug once cached', () => { + it('ignores the database-configured app slug for the advertised mention', () => { setConfiguredGitHubAppSlugCache({ value: 'acme', expiresAt: Date.now() + 60_000, @@ -80,7 +99,7 @@ describe('getPrBodyAttributionLine', () => { taskUrl: 'https://app.roomote.dev/tasks/123', }); - expect(line).toContain('@acme'); - expect(line).not.toContain('@octomote'); + expect(line).toContain('@roomote'); + expect(line).not.toContain('@acme'); }); }); diff --git a/packages/cloud-agents/src/server/workflows/utils.ts b/packages/cloud-agents/src/server/workflows/utils.ts index 111bc6f2..072e0f9c 100644 --- a/packages/cloud-agents/src/server/workflows/utils.ts +++ b/packages/cloud-agents/src/server/workflows/utils.ts @@ -4,6 +4,7 @@ import { buildSlackThreadPermalink, buildTeamsMessagePermalink, buildTelegramMessagePermalink, + ROOMOTE_CANONICAL_GITHUB_MENTION, getGitHubAppMention, resolveTaskWorkspace, } from '@roomote/types'; @@ -22,12 +23,14 @@ import { desc, asc, } from '@roomote/db/server'; -import { Schemas, getEffectiveGitHubAppSlug } from '@roomote/github'; +import { + Schemas, + getEffectiveGitHubAppSlug, + isCanonicalGitHubMentionEnabled, +} from '@roomote/github'; import { buildSlackThreadPromptBlocks } from '../../utils'; import type { ResolvedTaskCommitAuthor } from '../commit-author'; -const DEFAULT_R_GITHUB_APP_SLUG = 'roomote'; - export function resolveConflictResolverLabel( conflictResolverLabel?: string, ): string | undefined { @@ -51,7 +54,6 @@ export function getPrBodyAttributionLine({ teamsMessageId, teamsTenantId, teamsBotAppId, - githubAppSlug = getEffectiveGitHubAppSlug(), escapeDoubleQuotes = false, }: { attribution: ResolvedTaskCommitAuthor; @@ -80,7 +82,6 @@ export function getPrBodyAttributionLine({ teamsMessageId?: string; teamsTenantId?: string; teamsBotAppId?: string; - githubAppSlug?: string | null; escapeDoubleQuotes?: boolean; }) { if ( @@ -112,7 +113,6 @@ export function getPrBodyAttributionLine({ teamsMessageId, teamsTenantId, teamsBotAppId, - githubAppSlug, escapeDoubleQuotes, }); } @@ -134,7 +134,6 @@ function buildPrBodyAttributionLine({ teamsMessageId, teamsTenantId, teamsBotAppId, - githubAppSlug, escapeDoubleQuotes = false, }: { attribution: ResolvedTaskCommitAuthor; @@ -163,7 +162,6 @@ function buildPrBodyAttributionLine({ teamsMessageId?: string; teamsTenantId?: string; teamsBotAppId?: string; - githubAppSlug?: string | null; escapeDoubleQuotes?: boolean; }) { const escapeValue = (value: string) => @@ -180,9 +178,9 @@ function buildPrBodyAttributionLine({ const safeSlackConversationUrl = resolvedSlackConversationUrl ? escapeValue(resolvedSlackConversationUrl) : undefined; - const appMention = getGitHubAppMention( - githubAppSlug?.trim() || DEFAULT_R_GITHUB_APP_SLUG, - ); + const appMention = isCanonicalGitHubMentionEnabled() + ? ROOMOTE_CANONICAL_GITHUB_MENTION + : getGitHubAppMention(getEffectiveGitHubAppSlug()); const isChatSurface = taskSurface === 'slack' || taskSurface === 'teams' || diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index cfc7b8f6..a9dfdfbf 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -134,6 +134,10 @@ const serverSchema = { SANDBOX_OIDC_PUBLIC_KEY_SECONDARY: z.string().min(1).optional(), R_GITHUB_APP_ID: emptyStringDefault(), R_GITHUB_APP_SLUG: z.string().min(1).default('roomote'), + // Set to `true` to stop this deployment from answering the canonical + // `@roomote` GitHub mention alias (and advertising it in PR footers) — + // for fleets where several deployments share the same repositories. + R_GITHUB_DISABLE_CANONICAL_MENTION: z.string().optional(), R_GITHUB_APP_PRIVATE_KEY: emptyStringDefault(), R_GITHUB_CLIENT_ID: emptyStringDefault(), R_GITHUB_CLIENT_SECRET: emptyStringDefault(), diff --git a/packages/github/src/__tests__/resolve-app-slug.test.ts b/packages/github/src/__tests__/resolve-app-slug.test.ts index d255cfd7..da45e77b 100644 --- a/packages/github/src/__tests__/resolve-app-slug.test.ts +++ b/packages/github/src/__tests__/resolve-app-slug.test.ts @@ -107,46 +107,3 @@ describe('resolveConfiguredGitHubAppSlug', () => { await expect(resolveConfiguredGitHubAppSlug()).resolves.toBe('roomote'); }); }); - -describe('resolveConfiguredGitHubAppSlugIfConfigured', () => { - beforeEach(() => { - vi.resetModules(); - mockResolveDeploymentEnvVar.mockReset(); - delete process.env.R_GITHUB_APP_SLUG; - }); - - it('returns the configured slug when one is present', async () => { - mockResolveDeploymentEnvVar.mockResolvedValueOnce('roomote-roomote'); - - const { resolveConfiguredGitHubAppSlugIfConfigured } = - await import('../resolve-app-slug'); - - await expect(resolveConfiguredGitHubAppSlugIfConfigured()).resolves.toBe( - 'roomote-roomote', - ); - }); - - it('returns null when nothing is configured instead of the schema default', async () => { - mockResolveDeploymentEnvVar.mockResolvedValueOnce(null); - - const { resolveConfiguredGitHubAppSlugIfConfigured } = - await import('../resolve-app-slug'); - - await expect( - resolveConfiguredGitHubAppSlugIfConfigured(), - ).resolves.toBeNull(); - }); - - it('returns null on cold resolution failure so bodies are not downgraded', async () => { - mockResolveDeploymentEnvVar.mockRejectedValueOnce( - new Error('database unavailable'), - ); - - const { resolveConfiguredGitHubAppSlugIfConfigured } = - await import('../resolve-app-slug'); - - await expect( - resolveConfiguredGitHubAppSlugIfConfigured(), - ).resolves.toBeNull(); - }); -}); diff --git a/packages/github/src/app-slug.ts b/packages/github/src/app-slug.ts index 7dfb0fca..09dce4c3 100644 --- a/packages/github/src/app-slug.ts +++ b/packages/github/src/app-slug.ts @@ -1,4 +1,4 @@ -import { Env } from '@roomote/env'; +import { Env, isEnvFlagEnabled } from '@roomote/env'; /** * Cached result of resolving the deployment's configured GitHub App slug @@ -35,3 +35,12 @@ export function setConfiguredGitHubAppSlugCache( export function getEffectiveGitHubAppSlug(): string { return configuredAppSlugCache?.value ?? Env.R_GITHUB_APP_SLUG; } +/** + * Whether this deployment answers (and advertises) the canonical `@roomote` + * mention alias in addition to its own app slug. Disable with + * `R_GITHUB_DISABLE_CANONICAL_MENTION=true` when several deployments share + * the same repositories and a bare `@roomote` would make all of them respond. + */ +export function isCanonicalGitHubMentionEnabled(): boolean { + return !isEnvFlagEnabled(Env.R_GITHUB_DISABLE_CANONICAL_MENTION); +} diff --git a/packages/github/src/resolve-app-slug.ts b/packages/github/src/resolve-app-slug.ts index 2b503c56..3fe7eba3 100644 --- a/packages/github/src/resolve-app-slug.ts +++ b/packages/github/src/resolve-app-slug.ts @@ -46,48 +46,3 @@ export async function resolveConfiguredGitHubAppSlug(): Promise { return getEffectiveGitHubAppSlug(); } - -/** - * Like {@link resolveConfiguredGitHubAppSlug}, but returns only a slug that is - * actually configured in process env or the deployment env table. Returns - * `null` when nothing is configured or resolution fails cold, so callers do - * not confuse the schema default `'roomote'` with a real deployment setting. - * - * Prefer this when rewriting user-visible attribution text: a missing config - * should leave the agent-supplied body alone instead of downgrading a correct - * custom-slug mention to `@roomote`. - */ -export async function resolveConfiguredGitHubAppSlugIfConfigured(): Promise< - string | null -> { - const cache = getConfiguredGitHubAppSlugCache(); - - if (cache?.value && cache.expiresAt > Date.now()) { - return cache.value; - } - - try { - const slug = await resolveDeploymentEnvVar('R_GITHUB_APP_SLUG'); - - setConfiguredGitHubAppSlugCache({ - value: slug, - expiresAt: Date.now() + CONFIGURED_APP_SLUG_CACHE_TTL_MS, - }); - - return slug; - } catch (error) { - console.warn( - `[resolveConfiguredGitHubAppSlugIfConfigured] leaving body-owned mentions alone after slug resolution failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - - const lastKnown = getConfiguredGitHubAppSlugCache()?.value; - if (lastKnown) { - return lastKnown; - } - - const processSlug = process.env.R_GITHUB_APP_SLUG?.trim(); - return processSlug || null; - } -} diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index f3b357cb..1ef638f1 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -16,7 +16,6 @@ const { mockResolveAdoToken, mockResolveAdoBaseUrl, mockBuildAdoOrganizationApiBaseUrl, - mockResolveConfiguredGitHubAppSlugIfConfigured, } = vi.hoisted(() => ({ mockCreateGitHubToken: vi.fn(), mockGetDeploymentPrAction: vi.fn(), @@ -30,7 +29,6 @@ const { mockResolveAdoToken: vi.fn(), mockResolveAdoBaseUrl: vi.fn(), mockBuildAdoOrganizationApiBaseUrl: vi.fn(), - mockResolveConfiguredGitHubAppSlugIfConfigured: vi.fn(), })); vi.mock('@roomote/auth', () => ({ @@ -39,8 +37,6 @@ vi.mock('@roomote/auth', () => ({ vi.mock('@roomote/github', () => ({ getOctokit: (...args: unknown[]) => mockGetOctokit(...args), - resolveConfiguredGitHubAppSlugIfConfigured: (...args: unknown[]) => - mockResolveConfiguredGitHubAppSlugIfConfigured(...args), })); vi.mock('@roomote/gitlab', () => ({ @@ -149,7 +145,6 @@ function jsonResponse(body: unknown, status = 200): Response { describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockGetDeploymentPrAction.mockResolvedValue('draft'); mockEnvironmentsFindFirst.mockResolvedValue(null); mockResolveGitLabToken.mockResolvedValue('gitlab-token'); @@ -310,7 +305,6 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { describe('platform-managed draft state', () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockGetDeploymentPrAction.mockResolvedValue('draft'); mockEnvironmentsFindFirst.mockResolvedValue(null); mockResolveGitLabToken.mockResolvedValue('gitlab-token'); @@ -390,10 +384,9 @@ describe('platform-managed draft state', () => { expect(result.warnings).toEqual([]); }); - it('rewrites a hardcoded @roomote attribution mention to the configured app slug', async () => { - mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue( - 'roomote-roomote', - ); + it('passes the agent-supplied PR body through unchanged', async () => { + const body = + '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.'; const octokit = makeOctokit({ created: { number: 12, @@ -406,45 +399,11 @@ describe('platform-managed draft state', () => { await createOrUpdateSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ repo: 'acme/web' }), - input: { - ...githubInput, - body: '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', - }, + input: { ...githubInput, body }, }); expect(octokit.rest.pulls.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', - }), - ); - }); - - it('does not downgrade a correct custom-slug attribution when no slug is configured', async () => { - mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); - const preservedBody = - '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.'; - const octokit = makeOctokit({ - created: { - number: 13, - node_id: 'node-13', - html_url: 'https://github.com/acme/web/pull/13', - title: '[Feature] X', - draft: true, - }, - }); - - await createOrUpdateSourceControlPullRequestForTaskRun({ - taskRun: makeTaskRun({ repo: 'acme/web' }), - input: { - ...githubInput, - body: preservedBody, - }, - }); - - expect(octokit.rest.pulls.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: preservedBody, - }), + expect.objectContaining({ body }), ); }); @@ -635,7 +594,6 @@ describe('platform-managed draft state', () => { describe('optional targetBranch', () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockGetDeploymentPrAction.mockResolvedValue('draft'); mockEnvironmentsFindFirst.mockResolvedValue(null); mockResolveGitLabToken.mockResolvedValue('gitlab-token'); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index 706ac8ea..effa8c7e 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -15,10 +15,7 @@ import { resolveGiteaBaseUrl, resolveGiteaToken, } from '@roomote/gitea'; -import { - getOctokit, - resolveConfiguredGitHubAppSlugIfConfigured, -} from '@roomote/github'; +import { getOctokit } from '@roomote/github'; import { buildGitLabApiBaseUrl, resolveGitLabBaseUrl, @@ -35,7 +32,6 @@ import { import { buildPullRequestUrl, getSourceControlProviderLabel, - normalizePrBodyAttributionAppMention, prActions, resolveSourceControlProviderFromPayload, sourceControlProviderSchema, @@ -261,37 +257,18 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ const prAction = await resolveEffectivePrAction(taskRun); const createDraft = prAction !== 'create'; - // PR provenance mentions are injected into the agent prompt at task start - // via `getPrBodyAttributionLine`. When slug resolution at prompt-build time - // fell back to the schema default (`roomote`), the agent faithfully copies - // `@roomote` into the body. Repair that only when this process has a real - // configured slug — never rewrite with the default, or a correct custom - // handle (e.g. `@roomote-roomote`) could be downgraded. - const configuredGitHubAppSlug = - await resolveConfiguredGitHubAppSlugIfConfigured(); - const inputWithNormalizedAttribution: SourceControlPullRequestMutationInput = - configuredGitHubAppSlug - ? { - ...input, - body: normalizePrBodyAttributionAppMention( - input.body, - configuredGitHubAppSlug, - ), - } - : input; - const result = await (() => { switch (provider) { case 'github': return createOrUpdateGitHubPullRequest({ - input: inputWithNormalizedAttribution, + input: input, repository, provider, createDraft, }); case 'gitlab': return createOrUpdateGitLabMergeRequest({ - input: inputWithNormalizedAttribution, + input: input, repository, provider, createDraft, @@ -299,7 +276,7 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ }); case 'gitea': return createOrUpdateGiteaPullRequest({ - input: inputWithNormalizedAttribution, + input: input, repository, provider, createDraft, @@ -307,7 +284,7 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ }); case 'bitbucket': return createOrUpdateBitbucketPullRequest({ - input: inputWithNormalizedAttribution, + input: input, repository, provider, createDraft, @@ -315,7 +292,7 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ }); case 'ado': return createOrUpdateAdoPullRequest({ - input: inputWithNormalizedAttribution, + input: input, repository, provider, createDraft, diff --git a/packages/types/src/__tests__/github-bot-identity.test.ts b/packages/types/src/__tests__/github-bot-identity.test.ts index f40288cd..f0a350f5 100644 --- a/packages/types/src/__tests__/github-bot-identity.test.ts +++ b/packages/types/src/__tests__/github-bot-identity.test.ts @@ -2,7 +2,6 @@ import { getRoomoteGitHubAppSlugs, getRoomoteManagedGitHubLogins, matchesRoomoteGitHubLogin, - normalizePrBodyAttributionAppMention, } from '../constants'; describe('Roomote GitHub bot identity helpers', () => { @@ -64,41 +63,4 @@ describe('Roomote GitHub bot identity helpers', () => { expect(matchesRoomoteGitHubLogin('app/dependabot')).toBe(false); }); }); - - describe('normalizePrBodyAttributionAppMention', () => { - it('rewrites a hardcoded @roomote mention to the configured app slug', () => { - const body = - '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.'; - - expect( - normalizePrBodyAttributionAppMention(body, 'roomote-roomote'), - ).toBe( - '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', - ); - }); - - it('rewrites Opened on behalf of attribution mentions', () => { - const body = - '> Opened on behalf of Matt Rubens. [View the task](https://example.com/task/1) or mention @roomote for follow-up asks.'; - - expect(normalizePrBodyAttributionAppMention(body, 'openmote')).toBe( - '> Opened on behalf of Matt Rubens. [View the task](https://example.com/task/1) or mention @openmote for follow-up asks.', - ); - }); - - it('leaves already-correct mentions and non-attribution body text alone', () => { - const body = - '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\nSee also @roomote in the body.'; - - expect( - normalizePrBodyAttributionAppMention(body, 'roomote-roomote'), - ).toBe(body); - }); - - it('does nothing when there is no attribution line', () => { - const body = '## What changed\n\nNo provenance line.'; - - expect(normalizePrBodyAttributionAppMention(body, 'openmote')).toBe(body); - }); - }); }); diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts index 768cfc7c..56717be6 100644 --- a/packages/types/src/constants.ts +++ b/packages/types/src/constants.ts @@ -79,57 +79,16 @@ export function getGitHubAppMention(slug: string): string { } /** - * Leading Roomote PR provenance blockquote: - * `> Created by Roomote. ...` or `> Opened on behalf of . ...` - * (including the historical "from an unlinked ..." attribution form). + * Canonical mention handle every deployment listens for alongside its own + * configured app slug, and the handle product copy advertises. Roo Code owns + * the `roomote` GitHub App and user, so the alias cannot be claimed by a + * third party. */ -const PR_BODY_ATTRIBUTION_LINE_RE = - /^(>\s*(?:Created by Roomote(?: from an unlinked [^.]+)?\.|Opened on behalf of .+?\.)\s*)(.*)$/; +export const ROOMOTE_CANONICAL_GITHUB_MENTION = '@roomote'; /** - * Rewrite follow-up app mentions in the Roomote PR-body attribution line so - * they always use the deployment-configured GitHub App slug (for example - * `@roomote-roomote`) instead of a stale hostname default like `@roomote`. - * - * Only the leading attribution blockquote is rewritten; other body text that - * happens to mention `@roomote` is left unchanged. - */ -export function normalizePrBodyAttributionAppMention( - body: string, - githubAppSlug: string, -): string { - const normalizedSlug = githubAppSlug.trim(); - - if (!normalizedSlug) { - return body; - } - - const mention = getGitHubAppMention(normalizedSlug); - const firstNewline = body.indexOf('\n'); - const firstLine = firstNewline === -1 ? body : body.slice(0, firstNewline); - const remainder = firstNewline === -1 ? '' : body.slice(firstNewline); - const match = firstLine.match(PR_BODY_ATTRIBUTION_LINE_RE); - - if (!match) { - return body; - } - - const prefix = match[1] ?? ''; - const instruction = match[2] ?? ''; - const rewrittenInstruction = instruction.replace( - /(mention(?:ing)?\s+)@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)/g, - `$1${mention}`, - ); - - if (rewrittenInstruction === instruction) { - return body; - } - - return `${prefix}${rewrittenInstruction}${remainder}`; -} - -/** - * Hosted-product GitHub App slugs Roomote always treats as its own bots. + * Roo Code-owned GitHub App slugs every deployment treats as Roomote's own + * bots (the retired hosted product and Roo Code's internal deployments). * Custom deployments still add their configured slug via helpers below. */ export const ROOMOTE_GITHUB_HOSTED_APP_SLUGS = [