diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index 4172244fc..ae22e7739 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -532,3 +532,37 @@ jobs: -f "client_payload[image_tag]=${IMAGE_TAG}" \ -f "client_payload[sha]=${GITHUB_SHA}" \ -f "client_payload[ref]=${GITHUB_REF}" + + # Production Cloud rolls its tenant fleet from immutable main builds the + # same way staging follows develop builds. The Cloud-side workflow + # validates the tag against the commit before touching anything. + notify-production-cloud: + name: Notify production Cloud + runs-on: ubuntu-latest + needs: + - prepare + - publish + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.ROOMOTE_CLOUD_REPO != '' }} + permissions: + contents: read + steps: + - name: Send production repository dispatch + env: + GH_TOKEN: ${{ secrets.ROOMOTE_CLOUD_DISPATCH_TOKEN }} + CLOUD_REPO: ${{ vars.ROOMOTE_CLOUD_REPO }} + IMAGE_TAG: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + : "${GH_TOKEN:?ROOMOTE_CLOUD_DISPATCH_TOKEN is required}" + case "$CLOUD_REPO" in + RooCodeInc/Roomote-Cloud) ;; + *) + echo "Refusing unexpected ROOMOTE_CLOUD_REPO: $CLOUD_REPO" >&2 + exit 1 + ;; + esac + gh api "repos/${CLOUD_REPO}/dispatches" \ + -f event_type=roomote-main-build \ + -f "client_payload[image_tag]=${IMAGE_TAG}" \ + -f "client_payload[sha]=${GITHUB_SHA}" \ + -f "client_payload[ref]=${GITHUB_REF}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3e45275..52b3c8f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.19.0 (2026-07-23) + +This release makes pull-request review feedback clearer and safer to handle across chat, with more reliable task resumption. + +### Highlights + +- Resolve the feedback in a notification or have Roomote handle all future feedback on a pull request. +- Replying in a review-feedback thread retires its pending buttons so stale actions cannot conflict with the conversation. +- Resumed tasks retain their original source-control provider through older resume chains. + +### Minor changes + +- Make pull-request review feedback easier to handle by retiring stale offers after thread replies and providing clear actions to resolve selected or all issues. + +### Patch changes + +- Keep snapshot-resumed tasks connected to their original source-control provider when they resume through older task chains. + ## 0.18.0 (2026-07-23) This release makes pull-request feedback easier to act on across chat, extends Gitea automations, and improves task routing and reliability. diff --git a/README.md b/README.md index 1e2c8c653..0bd15a3d4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ repo into an isolated sandbox, writes the code, runs the tests, takes a screenshot, and opens a PR. You review the diff like you would from any teammate. No IDE plugin. No terminal session. No babysitting. It works while you do -something else. +something else, all the way. ``` "Fix the 500 on /api/billing for annual plans" @@ -19,14 +19,17 @@ something else. → you review, merge, done ``` -Source-available. Self-hostable. Use your ChatGPT subscription or bring your own +Source-available. Self-hostable or our Cloud. Use your ChatGPT subscription or bring your own API keys. +![Roomote chat workflow demo](assets/roomote-hero.gif) + +[![Deploy on Roomote Cloud](https://roomote.dev/images/deploy-button.png)](https://cloud.roomote.dev/sign-up) +   [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Rj2cFo?referralCode=roomote)    [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) -![Roomote chat workflow demo](assets/roomote-hero.gif) --- @@ -93,52 +96,40 @@ cleans up after itself. Pick one path. You will have a working Roomote instance at the end. -### Option A: One-click deploy (Railway) - -New to Railway? [Sign up with our referral link](https://railway.com?referralCode=roomote) -to get $20 in credit. +### Roomote Cloud (fastest, ~2 min) -1. Click the button: +Don't want to run infrastructure? We'll host your deployment: free 7-day trial, no credit card. Same single-tenant product — your own isolated instance, connect your ChatGPT subscription or bring an API key. Move to self-hosting anytime. [Start free →](https://cloud.roomote.dev/sign-up) - [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Rj2cFo?referralCode=roomote) +### One-click deploy (Railway) -2. Railway provisions Postgres, Redis, and the app. Follow the prompts to set - your admin email and password. +New to Railway? [Sign up with our referral link](https://railway.com?referralCode=roomote) +to get $20 in credit. -3. Open the setup link Railway gives you. Connect models. Two options: - - **ChatGPT subscription:** Sign in with your ChatGPT account. If you - already pay for Plus or Pro, you're done. No API key needed. - - **API key:** Paste a key from OpenRouter, Anthropic, OpenAI, or any other - supported provider. +1. [Use the template](https://railway.com/deploy/Rj2cFo?referralCode=roomote) -4. Connect source control: GitHub, GitLab, Gitea, Azure DevOps, or Bitbucket - Cloud. +2. Railway provisions Postgres, Redis, and the app. -5. Open Slack, send Roomote a message: +3. Open the setup link Railway gives you. Configure your communication, source control and inference providers. - ``` - What files are in the root of the repo? - ``` +4. Connect your sandbox provider. - **Expected output:** Roomote lists your repo's top-level files and asks what - you'd like to work on. +5. Open Slack/Telegram/Discord/Teams, send Roomote a message and you're off. -You now have a working cloud coding agent. Total time: ~3 minutes. +You now have a working cloud coding agent. Total time: ~6 minutes. -### Option B: One-click deploy (Render) +### One-click deploy (Render) -1. Click the button: +1. [Use the template](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) - [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) +2. Render provisions Postgres, Redis, and the app. -2. Render creates managed Postgres, Redis, and the app. Set your admin - credentials when prompted. +3. Open the setup link Render gives you. Configure your communication, source control and inference providers. -3. Open the setup link, connect a model provider and source control. +4. Connect your sandbox provider. -4. Send Roomote a test message in Slack. Same expected output as above. +5. Open Slack/Telegram/Discord/Teams, send Roomote a message and you're off. -### Option C: Self-host on your own server +### Self-host on your own server SSH into a fresh Ubuntu/Debian machine (4 GB RAM recommended) and run: @@ -147,13 +138,13 @@ curl -fsSL https://get.roomote.dev | bash ``` The installer handles Docker, secrets, and the Compose stack. It prints a setup -link when it finishes. Connect your model provider and repo, then send a test +link when it finishes. Follow the on-screen instructions, then send a test message. For production, point DNS at your server and pass `--domain roomote.example.com`. See also: [Coolify](deploy/coolify/README.md) and [Fly.io](deploy/fly/README.md) -deployment guides. +for more deployment guides. --- @@ -211,12 +202,9 @@ Features that matter at scale: ### Enterprise -Need SSO, custom SLAs, or dedicated support? Email +Need SSO, custom SLAs, or dedicated support? Get in touch: [help@roomote.dev](mailto:help@roomote.dev). -Want a managed deployment instead of self-hosting? We can run it for you. -[Get in touch](mailto:help@roomote.dev). - --- ## FAQ @@ -255,6 +243,7 @@ GitHub, GitLab, Gitea, Azure DevOps, and Bitbucket Cloud. Connect one or many. **What does it cost?** Self-hosting is free for up to 10 registered users. You pay your own model provider for tokens (or use the models bundled with your ChatGPT subscription). +Cloud starts at $49/mo, depending on total user count. For larger teams, licenses are available by emailing [help@roomote.dev](mailto:help@roomote.dev). diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 320054efc..0671d1916 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -83,6 +83,7 @@ vi.mock('@roomote/sdk/server', () => ({ restoreDiscordLinkCode: mocks.restoreLinkCode, upsertDiscordUserMapping: mocks.upsertUserMapping, upsertDiscordInstallation: mocks.upsertInstallation, + claimPendingPrReviewActionsForThread: vi.fn(async () => []), })); vi.mock('@roomote/sdk/server/communication', () => ({ diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 3266cc0df..dbc0487c0 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -44,6 +44,7 @@ import { resumeCommunicationTaskFromSnapshot, } from '@roomote/sdk/server/communication'; import { tryHandleDiscordRequestUserInputMessage } from './request-user-input.js'; +import { retireDiscordPrReviewOffersBestEffort } from './pr-review-action.js'; import { promptDiscordAccountLink } from './account-link.js'; import { processDiscordAttachments } from './attachments.js'; import { @@ -617,6 +618,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { activeRun.id, messageWithOutOfBand, ); + // A typed reply supersedes any pending PR review offers here. + retireDiscordPrReviewOffersBestEffort({ + channelId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId ?? null, + }); // Dedupe hit: nothing new will be delivered, so put claimed OOB // messages and undelivered thread claims back for a later real follow-up. if (!queued) { @@ -718,6 +724,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { channelId: channel.channelId, messageIds: [queuedMessage.ts], }); + // A typed reply supersedes any pending PR review offers here. + retireDiscordPrReviewOffersBestEffort({ + channelId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId ?? null, + }); return { ok: true, resumed: true, runId: resumed.id }; } catch (error) { await releaseDiscordContinuationClaim(continuationClaim); diff --git a/apps/api/src/handlers/discord/pr-review-action.ts b/apps/api/src/handlers/discord/pr-review-action.ts index 64c699d76..3c464c7c5 100644 --- a/apps/api/src/handlers/discord/pr-review-action.ts +++ b/apps/api/src/handlers/discord/pr-review-action.ts @@ -2,6 +2,7 @@ import type { DiscordInteraction } from '@roomote/communication/discord-event'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; import { claimPendingPrReviewAction, + claimPendingPrReviewActionsForThread, dispatchPrReviewFollowUp, enableAutoHandlePrReviewFeedback, findDiscordMappedUserId, @@ -83,7 +84,7 @@ export async function handleDiscordPrReviewActionCallback(input: { if (dispatched.outcome === 'unavailable') { await reply( input.choice === 'auto' - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply here to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply here to start fresh." : 'This task can no longer be resumed. Reply here to start fresh.', ); return; @@ -91,8 +92,8 @@ export async function handleDiscordPrReviewActionCallback(input: { await reply( input.choice === 'auto' - ? "I'll take it from here — future review feedback on this PR gets handled in this task. Looking at the current feedback now." - : 'On it — taking a look at the review feedback.', + ? "I'll resolve these and any future feedback on this PR automatically. Starting on the current feedback now." + : 'On it — resolving the review feedback.', ); } catch (error) { apiLogger.error( @@ -103,3 +104,31 @@ export async function handleDiscordPrReviewActionCallback(input: { await reply('Failed to start the follow-up. Reply here to ask again.'); } } + +/** + * Retires any pending PR review offers bound to a Discord conversation + * because a typed reply superseded them. Claims atomically so later clicks + * report "already handled"; the buttons stay visible but dead (Discord + * message component editing is not wired up yet). Fire-and-forget. + */ +export function retireDiscordPrReviewOffersBestEffort({ + channelId, + threadId, +}: { + channelId: string; + threadId: string | null; +}): void { + void (async () => { + await claimPendingPrReviewActionsForThread({ + provider: 'discord', + channelId, + threadId, + }); + })().catch((error: unknown) => { + apiLogger.warn( + `[discord] Failed to retire PR review offers for channel ${channelId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts b/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts new file mode 100644 index 000000000..e44745522 --- /dev/null +++ b/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts @@ -0,0 +1,102 @@ +const { claimForThreadMock, getMessageBlocksMock, updateMessageMock } = + vi.hoisted(() => ({ + claimForThreadMock: vi.fn(), + getMessageBlocksMock: vi.fn(), + updateMessageMock: vi.fn(), + })); + +vi.mock('@roomote/sdk/server', () => ({ + claimPendingPrReviewActionsForThread: claimForThreadMock, +})); + +import type { SlackNotifier } from '@roomote/slack'; + +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; + +const slack = { + getMessageBlocks: getMessageBlocksMock, + updateMessage: updateMessageMock, +} as unknown as SlackNotifier; + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.clearAllMocks(); + getMessageBlocksMock.mockResolvedValue([ + { type: 'section', text: { type: 'mrkdwn', text: 'summary' } }, + { + type: 'section', + block_id: 'pr_review_action_question', + text: { type: 'mrkdwn', text: 'Want me to take a look?' }, + }, + { type: 'actions', block_id: 'pr_review_action', elements: [] }, + { type: 'context', elements: [] }, + ]); + updateMessageMock.mockResolvedValue(true); +}); + +describe('retireSlackPrReviewOffersBestEffort', () => { + it('claims every offer in the thread and strips the buttons from each posted message', async () => { + claimForThreadMock.mockResolvedValue([ + { nonce: 'n1', messageId: '111.111' }, + { nonce: 'n2', messageId: '222.222' }, + ]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(claimForThreadMock).toHaveBeenCalledWith({ + provider: 'slack', + channelId: 'C123', + threadId: '100.000', + }); + expect(updateMessageMock).toHaveBeenCalledTimes(2); + const firstUpdate = updateMessageMock.mock.calls[0]?.[0]; + expect(firstUpdate.ts).toBe('111.111'); + const blocks = firstUpdate.message.blocks as Array>; + // Question and buttons are gone; a resolution note stands in their place. + expect( + blocks.some( + (block) => + block.block_id === 'pr_review_action' || + block.block_id === 'pr_review_action_question', + ), + ).toBe(false); + expect(JSON.stringify(blocks)).toContain( + 'Answered with a reply in the thread.', + ); + }); + + it('skips message edits for offers that never recorded a posted message', async () => { + claimForThreadMock.mockResolvedValue([{ nonce: 'n1', messageId: null }]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(updateMessageMock).not.toHaveBeenCalled(); + }); + + it('does nothing when the thread has no pending offers', async () => { + claimForThreadMock.mockResolvedValue([]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(getMessageBlocksMock).not.toHaveBeenCalled(); + expect(updateMessageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts index dc53ff5e9..41aca6206 100644 --- a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts +++ b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts @@ -218,7 +218,7 @@ describe('handleSlackPrReviewActionAuto', () => { expect.objectContaining({ elements: [ expect.objectContaining({ - text: expect.stringContaining('Taking it from here'), + text: expect.stringContaining('Resolving all issues'), }), ], }), @@ -237,7 +237,7 @@ describe('handleSlackPrReviewActionAuto', () => { expect(postSlackInteractiveResponseMock).toHaveBeenCalledWith( 'https://hooks.slack.test/response', expect.objectContaining({ - text: expect.stringContaining('take future feedback from here'), + text: expect.stringContaining('resolve future feedback'), }), ); // The message still resolves to the auto-handling note. diff --git a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts index a47111bf1..683f4b3eb 100644 --- a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts +++ b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts @@ -12,6 +12,7 @@ import { type PendingPrReviewAction, } from '@roomote/sdk/server'; import { + buildResolvedSlackPrReviewMessageBlocks, parseSlackPrReviewActionButtonValue, postSlackInteractiveResponse, type SlackInteractivePayload, @@ -34,48 +35,6 @@ async function getSlackTeamNotifier(teamId: string) { return { slack: new SlackNotifier(slackInstallation.botAccessToken) }; } -const QUESTION_BLOCK_ID = 'pr_review_action_question'; -const ACTIONS_BLOCK_ID = 'pr_review_action'; - -/** - * Rewrites the posted notification once the offer is resolved: the question - * and button blocks are replaced with a one-line resolution note while every - * other block (summary, relocated footers) is preserved as-is. - */ -function buildResolvedMessageBlocks( - originalBlocks: unknown[] | undefined, - resolution: string, -): unknown[] { - const resolutionBlock = { - type: 'context', - elements: [{ type: 'mrkdwn', text: resolution }], - }; - - if (!originalBlocks || originalBlocks.length === 0) { - return [resolutionBlock]; - } - - const kept = originalBlocks.filter((block) => { - const blockId = (block as { block_id?: unknown }).block_id; - - return blockId !== QUESTION_BLOCK_ID && blockId !== ACTIONS_BLOCK_ID; - }); - const actionsIndex = originalBlocks.findIndex( - (block) => (block as { block_id?: unknown }).block_id === ACTIONS_BLOCK_ID, - ); - // Insert the resolution where the buttons were; fall back to appending. - const removedBeforeActions = originalBlocks - .slice(0, actionsIndex < 0 ? 0 : actionsIndex) - .filter( - (block) => - (block as { block_id?: unknown }).block_id === QUESTION_BLOCK_ID, - ).length; - const insertAt = - actionsIndex < 0 ? kept.length : actionsIndex - removedBeforeActions; - - return [...kept.slice(0, insertAt), resolutionBlock, ...kept.slice(insertAt)]; -} - async function updateNotificationMessage({ payload, resolution, @@ -90,7 +49,10 @@ async function updateNotificationMessage({ channel: payload.channel.id, ts: payload.message.ts, message: { - blocks: buildResolvedMessageBlocks(payload.message.blocks, resolution), + blocks: buildResolvedSlackPrReviewMessageBlocks( + payload.message.blocks, + resolution, + ), }, }); } catch (error) { @@ -224,7 +186,7 @@ async function dispatchAcceptedPrReviewAction({ await respondEphemeral( payload, enableAutoHandle - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply in the thread to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply in the thread to start fresh." : 'This task can no longer be resumed. Reply in the thread to start fresh.', ); @@ -234,7 +196,7 @@ async function dispatchAcceptedPrReviewAction({ } const resolution = enableAutoHandle - ? `Taking it from here — requested by <@${payload.user.id}>. Future review feedback on this PR gets handled in this task.` + ? `Resolving all issues — requested by <@${payload.user.id}>. Future review feedback on this PR gets resolved in this task automatically.` : `On it — requested by <@${payload.user.id}>.`; await updateNotificationMessage({ payload, resolution }); diff --git a/apps/api/src/handlers/slack/events/active-run.ts b/apps/api/src/handlers/slack/events/active-run.ts index 24665541f..3564a4b6d 100644 --- a/apps/api/src/handlers/slack/events/active-run.ts +++ b/apps/api/src/handlers/slack/events/active-run.ts @@ -33,6 +33,7 @@ import { import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; import { apiLogger } from '../../../logging.js'; +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; import { syncActingUserForInboundMessage } from '../../tasks/acting-user-sync.js'; import { buildResolvedCurrentMessageText, @@ -527,6 +528,12 @@ export async function processActiveRunMessage( turnPolicy, }); await clearLatestUserMessage(activeRun.id); + // A typed reply supersedes any pending PR review offers in the thread. + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: event.channel, + threadTs: threadId, + }); } catch (error) { await deliveryTracker.rollback().catch(() => {}); throw error; diff --git a/apps/api/src/handlers/slack/events/snapshot-resume.ts b/apps/api/src/handlers/slack/events/snapshot-resume.ts index eacab0ff7..c2ad6407e 100644 --- a/apps/api/src/handlers/slack/events/snapshot-resume.ts +++ b/apps/api/src/handlers/slack/events/snapshot-resume.ts @@ -26,6 +26,7 @@ import { } from '@roomote/cloud-agents'; import { apiLogger } from '../../../logging.js'; +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; import { buildResolvedCurrentMessageText, processSlackAttachments, @@ -227,6 +228,12 @@ export async function processSnapshotResume( await queueSlackMessage(resumeRunId, queuedSlackMessage); await clearLatestUserMessage(resumeRunId); + // A typed reply supersedes any pending PR review offers in the thread. + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: event.channel, + threadTs: threadId, + }); deliveryTracker.track(event.ts); return true; } catch (error) { diff --git a/apps/api/src/handlers/slack/pr-review-retire.ts b/apps/api/src/handlers/slack/pr-review-retire.ts new file mode 100644 index 000000000..fc1686388 --- /dev/null +++ b/apps/api/src/handlers/slack/pr-review-retire.ts @@ -0,0 +1,61 @@ +import { claimPendingPrReviewActionsForThread } from '@roomote/sdk/server'; +import { + buildResolvedSlackPrReviewMessageBlocks, + type SlackNotifier, +} from '@roomote/slack'; + +import { apiLogger } from '../../logging.js'; + +/** + * Retires any pending PR review offers bound to a Slack thread because a + * typed reply superseded them: the person chose their own response, so the + * buttons must die. Claims the offers atomically (later clicks report + * "already handled") and rewrites each posted message without its buttons. + * Fire-and-forget: retirement must never block or fail message delivery. + */ +export function retireSlackPrReviewOffersBestEffort({ + slack, + channelId, + threadTs, +}: { + slack: SlackNotifier; + channelId: string; + threadTs: string; +}): void { + void (async () => { + const claimed = await claimPendingPrReviewActionsForThread({ + provider: 'slack', + channelId, + threadId: threadTs, + }); + + for (const pending of claimed) { + if (!pending.messageId) { + continue; + } + + const blocks = await slack.getMessageBlocks({ + channel: channelId, + messageTs: pending.messageId, + threadTs, + }); + + await slack.updateMessage({ + channel: channelId, + ts: pending.messageId, + message: { + blocks: buildResolvedSlackPrReviewMessageBlocks( + blocks, + 'Answered with a reply in the thread.', + ), + }, + }); + } + })().catch((error: unknown) => { + apiLogger.warn( + `[SlackPrReviewRetire] Failed to retire offers for ${channelId}/${threadTs}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 34939ef9c..9c8e0356d 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -261,6 +261,10 @@ vi.mock('@roomote/sdk/server', () => ({ /^link-[A-Za-z0-9_-]{16,}$/.test(value.trim()), findTelegramPrimaryChatId: vi.fn(async () => null), TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID', + claimPendingPrReviewAction: vi.fn(async () => null), + claimPendingPrReviewActionsForThread: vi.fn(async () => []), + dispatchPrReviewFollowUp: vi.fn(), + enableAutoHandlePrReviewFeedback: vi.fn(), })); vi.mock('@roomote/communication/telegram-provider', () => ({ diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 00f99fad6..8d8f4e774 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -28,6 +28,7 @@ import { findActiveTelegramTaskRun, findCompletedTelegramTaskRunWithSnapshot, } from './task-run-lookup.js'; +import { retireTelegramPrReviewOffersBestEffort } from './pr-review-action.js'; import { consumeTelegramLinkCode, isTelegramLinkCode, @@ -451,6 +452,11 @@ telegram.post('/', async (c) => { senderUserId: queuedMessage.userId, }); await queueCommunicationMessage('telegram', activeRun.id, queuedMessage); + // A typed reply supersedes any pending PR review offers in the chat. + retireTelegramPrReviewOffersBestEffort({ + chatId: conversation.chatId, + threadId: conversation.threadId ?? null, + }); // Track the latest inbound user message id so later outbound replies quote // the most recent user message instead of the original launch message. await setLatestInboundMessageId( @@ -535,6 +541,12 @@ telegram.post('/', async (c) => { metadata, }); + // A typed reply supersedes any pending PR review offers in the chat. + retireTelegramPrReviewOffersBestEffort({ + chatId: conversation.chatId, + threadId: conversation.threadId ?? null, + }); + await replyToTelegramSnapshotResume({ launchResult: resumeLaunch, conversation: { diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index da8d3db57..d470b3def 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -1,6 +1,7 @@ import type { TelegramCallbackQuery } from '@roomote/communication/telegram-update'; import { claimPendingPrReviewAction, + claimPendingPrReviewActionsForThread, dispatchPrReviewFollowUp, enableAutoHandlePrReviewFeedback, } from '@roomote/sdk/server'; @@ -105,7 +106,7 @@ export async function handleTelegramPrReviewActionCallback(params: { ...(messageId ? { replyToMessageId: messageId } : {}), text: choice === 'auto' - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply here to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply here to start fresh." : 'This task can no longer be resumed. Reply here to start fresh.', }); } @@ -117,7 +118,7 @@ export async function handleTelegramPrReviewActionCallback(params: { await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, - text: choice === 'auto' ? 'Taking it from here.' : 'On it.', + text: choice === 'auto' ? 'Resolving all issues.' : 'On it.', }); if (chatId && messageId) { @@ -130,8 +131,8 @@ export async function handleTelegramPrReviewActionCallback(params: { replyToMessageId: messageId, text: choice === 'auto' - ? "I'll take it from here — future review feedback on this PR gets handled in this task. Looking at the current feedback now." - : 'On it — taking a look at the review feedback.', + ? "I'll resolve these and any future feedback on this PR automatically. Starting on the current feedback now." + : 'On it — resolving the review feedback.', }); } } @@ -147,3 +148,39 @@ export async function handleTelegramPrReviewActionCallback(params: { }); } } + +/** + * Retires any pending PR review offers bound to a Telegram conversation + * because a typed reply superseded them. Claims atomically and strips the + * buttons from each posted offer. Fire-and-forget. + */ +export function retireTelegramPrReviewOffersBestEffort({ + chatId, + threadId, +}: { + chatId: string; + threadId: string | null; +}): void { + void (async () => { + const claimed = await claimPendingPrReviewActionsForThread({ + provider: 'telegram', + channelId: chatId, + threadId, + }); + + for (const pending of claimed) { + if (pending.messageId) { + await clearTelegramMessageButtonsBestEffort({ + chatId, + messageId: pending.messageId, + }); + } + } + })().catch((error: unknown) => { + apiLogger.warn( + `[telegram] Failed to retire PR review offers for chat ${chatId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index eda0e9d54..895d4a2c0 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -101,6 +101,7 @@ vi.mock('@roomote/sdk/server', () => ({ recordPrReviewNotificationDeliveryBestEffort: mockRecordDelivery, setPendingPrReviewAction: mockSetPendingPrReviewAction, dispatchPrReviewFollowUp: mockDispatchFollowUp, + attachPendingPrReviewActionMessage: vi.fn(), })); import type { Job } from 'bullmq'; @@ -310,11 +311,11 @@ describe('prReviewNotificationJob', () => { buttons: [ [ expect.objectContaining({ - text: 'Yes, take a look', + text: 'Resolve these issues', callbackData: `prr:y:${storedNonce}`, }), expect.objectContaining({ - text: 'Take it from here', + text: 'Resolve all issues', callbackData: `prr:a:${storedNonce}`, }), expect.objectContaining({ diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index 6a4116f10..200a75f65 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -15,6 +15,7 @@ import { import { PR_REVIEW_NOTIFICATION_DEFER_MS, PR_REVIEW_NOTIFICATION_MAX_DEFERRALS, + attachPendingPrReviewActionMessage, getCommunicationProviderAdapter, type PrReviewNotificationRequest, type PrReviewNotificationRoute, @@ -138,7 +139,7 @@ async function postPrReviewNotification({ const slack = new SlackNotifier(slackInstallation.botAccessToken); - return postSlackThreadMessageWithStickyFooter({ + const messageTs = await postSlackThreadMessageWithStickyFooter({ slack, channel: route.channelId, threadTs: route.threadId, @@ -155,6 +156,12 @@ async function postPrReviewNotification({ : {}), utmCampaign: 'slack.pr_review', }); + + if (nonce && messageTs) { + await attachPendingPrReviewActionMessage(nonce, messageTs); + } + + return messageTs; } const adapter = await getCommunicationProviderAdapter(route.provider); @@ -172,11 +179,11 @@ async function postPrReviewNotification({ postInput.buttons = [ [ { - text: 'Yes, take a look', + text: 'Resolve these issues', callbackData: buildPrReviewActionCallbackData('yes', nonce), }, { - text: 'Take it from here', + text: 'Resolve all issues', callbackData: buildPrReviewActionCallbackData('auto', nonce), }, { @@ -187,7 +194,11 @@ async function postPrReviewNotification({ ]; } - await adapter.postMessage(postInput); + const posted = await adapter.postMessage(postInput); + + if (nonce && posted?.messageId) { + await attachPendingPrReviewActionMessage(nonce, posted.messageId); + } return null; } diff --git a/package.json b/package.json index c18091bd2..557255cd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.18.0", + "version": "0.19.0", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index 7d349de00..ccaee1de7 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -728,6 +728,156 @@ describe('enqueueTask snapshot resume', () => { expect(runsForTask).toHaveLength(2); }); + it('inherits source-control stamps from the source run payload', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'roomote/Test ADO/Test ADO', + description: 'Do the thing', + sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }); + + // Resume entry points rebuild the payload from scratch and historically + // dropped the provider stamp, which made workspace prep resolve the + // repository against the GitHub default and fail. + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-ado-1', + sourceRunId: freshRun.id, + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + const resumePayload = resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + }; + + expect(resumePayload.sourceControlProvider).toBe('ado'); + expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); + }); + + it('walks the resume chain for stamps when the source run predates inheritance', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'roomote/Test ADO/Test ADO', + description: 'Do the thing', + sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }); + + // Simulate a resume row created before stamps were inherited: it points + // at the stamped fresh run but its own rebuilt payload carries no stamps. + const [legacyResume] = await db + .insert(taskRuns) + .values({ + taskId: freshRun.taskId, + kind: 'resume', + sourceRunId: freshRun.id, + payloadKind: TaskPayloadKind.SnapshotResume, + status: RunStatus.Completed, + sourceSnapshotId: 'snap-legacy-1', + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-legacy-1', + sourceRunId: freshRun.id, + }, + }) + .returning(); + + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-legacy-1', + sourceRunId: legacyResume!.id, + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + const resumePayload = resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + }; + + expect(resumePayload.sourceControlProvider).toBe('ado'); + expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); + }); + + it('keeps an explicit source-control provider on the resume payload', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Do the thing', + sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'acme/widgets', + sourceSnapshotId: 'snap-explicit-1', + sourceRunId: freshRun.id, + sourceControlProvider: 'gitea', + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + expect( + ( + resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + } + ).sourceControlProvider, + ).toBe('gitea'); + expect( + (resumeRun.payload as { sourceControlHost?: string }).sourceControlHost, + ).toBeUndefined(); + }); + it('rejects a resume without a source run id', async () => { const resumeTask = { type: TaskPayloadKind.SnapshotResume, diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index a1e4b160e..cb750a020 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -1917,7 +1917,9 @@ function inheritSnapshotResumeSourceControlStamps( sourceControlHost?: unknown; }; - if (payload.sourceControlProvider === undefined) { + const inheritsProvider = payload.sourceControlProvider === undefined; + + if (inheritsProvider) { const provider = sourceControlProviderSchema.safeParse( source.sourceControlProvider, ); @@ -1927,7 +1929,7 @@ function inheritSnapshotResumeSourceControlStamps( } } - if (payload.sourceControlHost === undefined) { + if (inheritsProvider && payload.sourceControlHost === undefined) { const host = typeof source.sourceControlHost === 'string' ? source.sourceControlHost.trim() @@ -2012,13 +2014,18 @@ async function enqueueSnapshotResume( ) { const parentRun = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, parentRunId), - columns: { payloadKind: true, sourceRunId: true }, + columns: { payloadKind: true, sourceRunId: true, payload: true }, }); if (!parentRun) { break; } + // Resume rows created before stamps were inherited may lack them even + // though an ancestor has them; pick up whatever is still missing while + // walking, nearest ancestor first. + inheritSnapshotResumeSourceControlStamps(task.payload, parentRun.payload); + sourceTaskType = parentRun.payloadKind; parentRunId = parentRun.sourceRunId; } diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts new file mode 100644 index 000000000..db0e813ff --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts @@ -0,0 +1,52 @@ +const mockEval = vi.fn(); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ eval: mockEval }), +})); + +import { + attachPendingPrReviewActionMessage, + claimPendingPrReviewActionsForThread, +} from '../pr-review-action'; + +describe('PR review action state', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('attaches notification ids with an atomic compare-and-update script', async () => { + mockEval.mockResolvedValue(1); + + await attachPendingPrReviewActionMessage('nonce-1', 'message-1'); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('get', KEYS[1])"), + 1, + 'pr-review-action:nonce-1', + 'message-1', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("'KEEPTTL'"); + }); + + it('claims every indexed offer through one atomic script', async () => { + mockEval.mockResolvedValue([ + JSON.stringify({ nonce: 'nonce-1', messageId: 'message-1' }), + ]); + + await expect( + claimPendingPrReviewActionsForThread({ + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + }), + ).resolves.toEqual([{ nonce: 'nonce-1', messageId: 'message-1' }]); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('smembers', KEYS[1])"), + 1, + 'pr-review-action:thread:discord:channel-1:thread-1', + 'pr-review-action:', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("redis.call('del', KEYS[1])"); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 03ec035a1..1b8ecdf08 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -433,7 +433,7 @@ describe('triagePrReviewActivity', () => { expect(system).toContain( 'current pull request state includes "- Merge conflicts: yes"', ); - expect(system).toContain('Do you want me to fix it?'); + expect(system).toContain('Would you like me to resolve this?'); }); it('includes the latest Roomote review summary comment verbatim for self-review results', async () => { diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts index f7b62d755..e0304be6a 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts @@ -34,6 +34,12 @@ export interface PendingPrReviewAction { * summary. */ followUpPrompt: string; + /** + * Provider-native id of the posted notification message (Slack ts, Discord + * or Telegram message id), attached after posting so the offer can be + * visually retired later. + */ + messageId?: string | null; } // GETDEL is atomic: exactly one clicker receives the record; every later @@ -45,21 +51,86 @@ redis.call('del', KEYS[1]) return val `; +// Attaching a message must not revive an offer that a typed reply or button +// click claimed after the notification was posted. +const ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA = ` +local val = redis.call('get', KEYS[1]) +if not val then return 0 end +local pending = cjson.decode(val) +pending.messageId = ARGV[1] +redis.call('set', KEYS[1], cjson.encode(pending), 'KEEPTTL') +return 1 +`; + +// Read, clear, and claim the complete conversation index in one operation so +// offers added concurrently remain indexed for a later typed reply. +const CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA = ` +local nonces = redis.call('smembers', KEYS[1]) +if #nonces == 0 then return {} end +redis.call('del', KEYS[1]) +local claimed = {} +for _, nonce in ipairs(nonces) do + local actionKey = ARGV[1] .. nonce + local val = redis.call('get', actionKey) + if val then + redis.call('del', actionKey) + table.insert(claimed, val) + end +end +return claimed +`; + function getPrReviewActionKey(nonce: string): string { return `${PR_REVIEW_ACTION_PREFIX}${nonce}`; } +// Secondary index: every pending offer nonce for a conversation, so a typed +// reply in the thread can retire all of them at once. +function getPrReviewActionThreadKey(input: { + provider: PrReviewActionProvider; + channelId: string; + threadId: string | null; +}): string { + return `${PR_REVIEW_ACTION_PREFIX}thread:${input.provider}:${input.channelId}:${input.threadId ?? '-'}`; +} + export async function setPendingPrReviewAction( pending: PendingPrReviewAction, ): Promise { const redis = getRedis(); + const threadKey = getPrReviewActionThreadKey(pending); - await redis.set( - getPrReviewActionKey(pending.nonce), - JSON.stringify(pending), - 'EX', - PR_REVIEW_ACTION_TTL_SECONDS, - ); + await redis + .multi() + .set( + getPrReviewActionKey(pending.nonce), + JSON.stringify(pending), + 'EX', + PR_REVIEW_ACTION_TTL_SECONDS, + ) + .sadd(threadKey, pending.nonce) + .expire(threadKey, PR_REVIEW_ACTION_TTL_SECONDS) + .exec(); +} + +/** + * Records the posted notification message id on an already-stored pending + * offer so retirement can edit the message later. No-op when the offer was + * already claimed. + */ +export async function attachPendingPrReviewActionMessage( + nonce: string, + messageId: string, +): Promise { + const redis = getRedis(); + await redis + .eval( + ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA, + 1, + getPrReviewActionKey(nonce), + messageId, + ) + .catch(() => undefined); } export async function claimPendingPrReviewAction( @@ -77,12 +148,55 @@ export async function claimPendingPrReviewAction( } try { - return JSON.parse(raw) as PendingPrReviewAction; + const pending = JSON.parse(raw) as PendingPrReviewAction; + + await redis + .srem(getPrReviewActionThreadKey(pending), nonce) + .catch(() => undefined); + + return pending; } catch { return null; } } +/** + * Claims every pending offer bound to a conversation — used when a typed + * reply lands in the thread, which supersedes the offers: the person chose + * their own response, so the buttons must die. Returns the claimed records + * so callers can visually retire the posted messages. + */ +export async function claimPendingPrReviewActionsForThread(input: { + provider: PrReviewActionProvider; + channelId: string; + threadId: string | null; +}): Promise { + const redis = getRedis(); + const threadKey = getPrReviewActionThreadKey(input); + const rawClaims = await redis.eval( + CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA, + 1, + threadKey, + PR_REVIEW_ACTION_PREFIX, + ); + + const claimed: PendingPrReviewAction[] = []; + + for (const raw of Array.isArray(rawClaims) ? rawClaims : []) { + if (typeof raw !== 'string') { + continue; + } + + try { + claimed.push(JSON.parse(raw) as PendingPrReviewAction); + } catch { + // Malformed record; skip. + } + } + + return claimed; +} + /** * Marks a task's PR so future review feedback is dispatched into the task * automatically instead of asking first. The enabling user becomes the acting diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index b45570c44..d3bd4ffe6 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -438,8 +438,11 @@ offers to act go in "followUpQuestion" and "followUpPrompt" instead. Rules: headers - when the feedback contains findings, requested changes, failed CI, or merge conflicts that are not already handled, write "followUpQuestion" as - one short question asking whether the user wants the agent to work on them - (for example: Want me to take a look?), and write "followUpPrompt" as a + one short question asking whether the user wants the agent to resolve + them, phrased with the verb "resolve" so it matches the reply buttons + labeled "Resolve these issues" and "Resolve all issues" (for example: + Would you like me to resolve these issues? or, for a single finding, + Would you like me to resolve this issue?), and write "followUpPrompt" as a self-contained imperative instruction to the coding agent describing exactly what to investigate and address - name the feedback source, the affected pull request, any failed check, and include the relevant links @@ -463,7 +466,7 @@ offers to act go in "followUpQuestion" and "followUpPrompt" instead. Rules: "looked good" review wrap-up. Prefer a summary shape like "I reviewed [owner/repo#42](pull request URL) on GitHub and the code looked good overall, but a test is failing in CI." with the offer to fix it carried by - "followUpQuestion" (for example: Do you want me to fix it?). Name the + "followUpQuestion" (for example: Would you like me to resolve this?). Name the failed check when one is listed. Pending checks may get a brief mention but must not overshadow a hard failure - when open feedback is already actionable (findings, requested changes, or diff --git a/packages/sdk/trigger-automation-now.ts b/packages/sdk/trigger-automation-now.ts new file mode 100644 index 000000000..89d341f56 --- /dev/null +++ b/packages/sdk/trigger-automation-now.ts @@ -0,0 +1,25 @@ +/** + * Local repro trigger: runs a background automation exactly like the web + * "Run now" button (manualTrigger: true). Untracked; delete after the repro. + * + * Usage: + * pnpm exec dotenvx run -f ../../.env.local -- pnpm exec tsx trigger-automation-now.ts + */ +import { runAutomationNow } from './src/server/automations'; + +const key = process.argv[2]; + +if (!key) { + console.error('Usage: tsx trigger-automation-now.ts '); + process.exit(1); +} + +runAutomationNow(key as never) + .then((result) => { + console.log('run-now result:', JSON.stringify(result, null, 2)); + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/packages/slack/src/pr-review-action.ts b/packages/slack/src/pr-review-action.ts index 5b4c14176..b9fe06617 100644 --- a/packages/slack/src/pr-review-action.ts +++ b/packages/slack/src/pr-review-action.ts @@ -60,7 +60,11 @@ export function buildSlackPrReviewActionBlocks(params: { { type: 'button', action_id: PR_REVIEW_ACTION_YES_ACTION_ID, - text: { type: 'plain_text', text: 'Yes, take a look', emoji: true }, + text: { + type: 'plain_text', + text: 'Resolve these issues', + emoji: true, + }, style: 'primary', value, }, @@ -69,7 +73,7 @@ export function buildSlackPrReviewActionBlocks(params: { action_id: PR_REVIEW_ACTION_AUTO_ACTION_ID, text: { type: 'plain_text', - text: 'Take it from here', + text: 'Resolve all issues', emoji: true, }, value, @@ -84,3 +88,45 @@ export function buildSlackPrReviewActionBlocks(params: { }, ] as SlackBlock[]; } + +const QUESTION_BLOCK_ID = 'pr_review_action_question'; +const ACTIONS_BLOCK_ID = 'pr_review_action'; + +/** + * Rewrites a posted PR review offer once it is resolved or superseded: the + * question and button blocks are replaced with a one-line resolution note + * while every other block (summary, relocated footers) is preserved as-is. + */ +export function buildResolvedSlackPrReviewMessageBlocks( + originalBlocks: unknown[] | undefined | null, + resolution: string, +): unknown[] { + const resolutionBlock = { + type: 'context', + elements: [{ type: 'mrkdwn', text: resolution }], + }; + + if (!originalBlocks || originalBlocks.length === 0) { + return [resolutionBlock]; + } + + const kept = originalBlocks.filter((block) => { + const blockId = (block as { block_id?: unknown }).block_id; + + return blockId !== QUESTION_BLOCK_ID && blockId !== ACTIONS_BLOCK_ID; + }); + const actionsIndex = originalBlocks.findIndex( + (block) => (block as { block_id?: unknown }).block_id === ACTIONS_BLOCK_ID, + ); + // Insert the resolution where the buttons were; fall back to appending. + const removedBeforeActions = originalBlocks + .slice(0, actionsIndex < 0 ? 0 : actionsIndex) + .filter( + (block) => + (block as { block_id?: unknown }).block_id === QUESTION_BLOCK_ID, + ).length; + const insertAt = + actionsIndex < 0 ? kept.length : actionsIndex - removedBeforeActions; + + return [...kept.slice(0, insertAt), resolutionBlock, ...kept.slice(insertAt)]; +}