Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ This changelog documents internal development changes, refactors, tooling update

## [Unreleased]

### Added
- Added `getSlackBotToken()` helper to `SlackChatAdapter` that falls back to `process.env.SLACK_BOT_TOKEN` when the event's `slackBotToken` is undefined. Applied across all 4 token-consuming methods (`fetchThreadContext`, `postReply`, `acknowledgeReceipt`, `notifyBusy`). Added explanatory comment in `SlackEventTransport.processAndEmitEvent()` noting the downstream fallback. ([CYPACK-842](https://linear.app/ceedar/issue/CYPACK-842), [#896](https://github.com/ceedaragents/cyrus/pull/896))
- Added `pull_request_review` event type support to `cyrus-github-event-transport`: new `GitHubReview` and `GitHubPullRequestReviewPayload` types, `isPullRequestReviewPayload` type guard, updated `isPullRequestReviewCommentPayload` to disambiguate via `!("review" in payload)`, extended all extractor functions (`extractCommentBody`, `extractCommentAuthor`, `extractCommentId`, `extractCommentUrl`, `extractPRBranchRef`, `extractPRNumber`, `extractPRTitle`, `isCommentOnPullRequest`), and added `translatePullRequestReview`/`translatePullRequestReviewAsUserPrompt` to `GitHubMessageTranslator`. Extended `GitHubEventType` union and `GitHubWebhookEvent.payload` union. Updated `GitHubSessionStartPlatformData` and `GitHubUserPromptPlatformData` `eventType` fields in `cyrus-core`. Added `buildGitHubChangeRequestSystemPrompt` to EdgeWorker with two branches: non-empty review body shows reviewer feedback, empty review body instructs agent to use `gh api` to read PR review comments. Added acknowledgement comment posting via `postIssueComment` before starting agent session. Added defensive `changes_requested` state check. ([CYPACK-842](https://linear.app/ceedar/issue/CYPACK-842), [#896](https://github.com/ceedaragents/cyrus/pull/896))

## [0.2.25] - 2026-02-27

### Fixed
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

### Added
- **PR change request handling** - When a reviewer requests changes on a PR created by Cyrus, the agent now automatically acknowledges the review and starts working on the requested changes. Supports both summary-level and line-level review comments. ([CYPACK-842](https://linear.app/ceedar/issue/CYPACK-842), [#896](https://github.com/ceedaragents/cyrus/pull/896))
- **Direct Slack webhook verification for self-hosted deployments** - Cyrus can now verify Slack webhooks directly using HMAC-SHA256 signature verification when `SLACK_SIGNING_SECRET` is set, removing the need for the CYHOST proxy in self-hosted environments. Thanks to [@aniravi24](https://github.com/aniravi24) ([#829](https://github.com/ceedaragents/cyrus/pull/829))
- **GitHub bot mention filtering** - GitHub webhook handler now respects `GITHUB_BOT_USERNAME` to only trigger on `@bot` mentions and ignore its own comments, preventing infinite loops in self-hosted setups. Thanks to [@aniravi24](https://github.com/aniravi24) ([#829](https://github.com/ceedaragents/cyrus/pull/829))
- **Smarter Slack thread context** - Other bots' messages (Sentry, CI, GitHub notifications) are now preserved in Slack thread context instead of being filtered out. Only the bot's own messages are excluded. Thanks to [@aniravi24](https://github.com/aniravi24) ([#829](https://github.com/ceedaragents/cyrus/pull/829))
-

### Fixed
- **Slack bot token availability after runtime switch** - Fixed Slack bot token not being available when switching from cloud to self-host runtime. The token is now resolved at usage time with a fallback to `process.env`, handling cases where the env update arrives after the first webhook. ([CYPACK-842](https://linear.app/ceedar/issue/CYPACK-842), [#896](https://github.com/ceedaragents/cyrus/pull/896))

## [0.2.26] - 2026-02-28 ([#918](https://github.com/ceedaragents/cyrus/pull/918))

### Changed
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/messages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ export interface LinearSessionStartPlatformData {
*/
export interface GitHubSessionStartPlatformData {
/** The event type that triggered this session */
eventType: "issue_comment" | "pull_request_review_comment";
eventType:
| "issue_comment"
| "pull_request_review_comment"
| "pull_request_review";
/** Repository information */
repository: GitHubPlatformRef["repository"];
/** Pull request information (if available) */
Expand Down Expand Up @@ -187,7 +190,10 @@ export interface LinearUserPromptPlatformData {
*/
export interface GitHubUserPromptPlatformData {
/** The event type */
eventType: "issue_comment" | "pull_request_review_comment";
eventType:
| "issue_comment"
| "pull_request_review_comment"
| "pull_request_review";
/** Repository information */
repository: GitHubPlatformRef["repository"];
/** The comment containing the prompt */
Expand Down
127 changes: 111 additions & 16 deletions packages/edge-worker/src/EdgeWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
isCommentOnPullRequest,
isIssueCommentPayload,
isPullRequestReviewCommentPayload,
isPullRequestReviewPayload,
stripMention,
} from "cyrus-github-event-transport";
import {
Expand Down Expand Up @@ -794,10 +795,17 @@ export class EdgeWorker extends EventEmitter {
this.logger,
);

// Auto-detect direct mode when SLACK_SIGNING_SECRET is set
const useDirectSlackWebhooks =
// Use direct Slack signature verification only when BOTH:
// 1. SLACK_SIGNING_SECRET is set (we have the secret to verify)
// 2. CYRUS_HOST_EXTERNAL is true (self-hosted: Slack sends directly to us)
// On cloud droplets, CYHOST forwards webhooks with Bearer token auth
// (it verifies the Slack signature itself and doesn't forward the headers).
const isExternalHost =
process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
const hasSlackSigningSecret =
process.env.SLACK_SIGNING_SECRET != null &&
process.env.SLACK_SIGNING_SECRET !== "";
const useDirectSlackWebhooks = isExternalHost && hasSlackSigningSecret;

const slackVerificationMode = useDirectSlackWebhooks ? "direct" : "proxy";
const slackSecret = useDirectSlackWebhooks
Expand Down Expand Up @@ -855,6 +863,8 @@ export class EdgeWorker extends EventEmitter {
const prTitle = extractPRTitle(event);
const sessionKey = extractSessionKey(event);

const isPullRequestReview = isPullRequestReviewPayload(event.payload);

// Skip comments from the bot itself to prevent infinite loops
const botUsername = process.env.GITHUB_BOT_USERNAME;
if (botUsername && commentAuthor === botUsername) {
Expand All @@ -864,21 +874,37 @@ export class EdgeWorker extends EventEmitter {
return;
}

// For pull_request_review events, defensively check review state
// (must happen before the mention check — reviews don't contain @mentions)
if (isPullRequestReviewPayload(event.payload)) {
if (event.payload.review.state !== "changes_requested") {
this.logger.debug(
`Ignoring pull_request_review with state: ${event.payload.review.state}`,
);
return;
}
}

// Only trigger on comments that mention the bot (when configured)
if (botUsername && !commentBody.includes(`@${botUsername}`)) {
// Skip this check for pull_request_review events — reviews don't @mention the bot
if (
!isPullRequestReview &&
botUsername &&
!commentBody.includes(`@${botUsername}`)
) {
this.logger.debug(
`Ignoring comment without @${botUsername} mention on ${repoFullName}#${prNumber}`,
);
return;
}

this.logger.info(
`Processing GitHub webhook: ${repoFullName}#${prNumber} by @${commentAuthor}`,
`Processing GitHub webhook: ${repoFullName}#${prNumber} by @${commentAuthor}${isPullRequestReview ? " (pull_request_review)" : ""}`,
);

// Add "eyes" reaction to acknowledge receipt
// Add "eyes" reaction to acknowledge receipt (not for pull_request_review — we post a comment instead)
const reactionToken = event.installationToken || process.env.GITHUB_TOKEN;
if (reactionToken) {
if (reactionToken && !isPullRequestReview) {
const commentId = extractCommentId(event);
if (commentId) {
this.gitHubCommentService
Expand Down Expand Up @@ -918,6 +944,23 @@ export class EdgeWorker extends EventEmitter {
return;
}

// For pull_request_review events, post an instant acknowledgement comment
if (isPullRequestReview && reactionToken && prNumber) {
this.gitHubCommentService
.postIssueComment({
token: reactionToken,
owner: extractRepoOwner(event),
repo: extractRepoName(event),
issueNumber: prNumber,
body: "Received your change request. Getting started on those changes now.",
})
.catch((err: unknown) => {
this.logger.warn(
`Failed to post acknowledgement comment: ${err instanceof Error ? err.message : err}`,
);
});
}

// Determine the PR branch
let branchRef = extractPRBranchRef(event);

Expand All @@ -927,22 +970,26 @@ export class EdgeWorker extends EventEmitter {
branchRef = await this.fetchPRBranchRef(event, repository);
}

if (!branchRef) {
if (!branchRef || !prNumber) {
this.logger.error(
`Could not determine branch for ${repoFullName}#${prNumber}`,
`Could not determine branch or PR number for ${repoFullName}#${prNumber}`,
);
return;
}

// Strip the bot mention to get the task instructions
// For pull_request_review, the review body IS the task context (no mention to strip)
// For other events, strip the bot mention to get the task instructions
const mentionHandle = botUsername ? `@${botUsername}` : "@cyrusagent";
const taskInstructions = stripMention(commentBody, mentionHandle);
const taskInstructions = isPullRequestReview
? commentBody ||
"A reviewer has requested changes on this PR. Read the review comments to understand what needs to be changed."
: stripMention(commentBody, mentionHandle);

// Create workspace (git worktree) for the PR branch
const workspace = await this.createGitHubWorkspace(
repository,
branchRef,
prNumber!,
prNumber,
);

if (!workspace) {
Expand Down Expand Up @@ -999,11 +1046,13 @@ export class EdgeWorker extends EventEmitter {
session.metadata.commentId = String(extractCommentId(event));

// Build the system prompt for this GitHub PR session
const systemPrompt = this.buildGitHubSystemPrompt(
event,
branchRef,
taskInstructions,
);
const systemPrompt = isPullRequestReview
? this.buildGitHubChangeRequestSystemPrompt(
event,
branchRef,
taskInstructions,
)
: this.buildGitHubSystemPrompt(event, branchRef, taskInstructions);

// Build allowed tools and directories
// Exclude Slack MCP tools from GitHub sessions
Expand Down Expand Up @@ -1239,6 +1288,52 @@ ${taskInstructions}
- Be concise in your responses as they will be posted back to the GitHub PR`;
}

/**
* Build a system prompt for a GitHub PR change request review session.
*/
private buildGitHubChangeRequestSystemPrompt(
event: GitHubWebhookEvent,
branchRef: string,
reviewBody: string,
): string {
const repoFullName = extractRepoFullName(event);
const prNumber = extractPRNumber(event);
const prTitle = extractPRTitle(event);
const commentAuthor = extractCommentAuthor(event);
const commentUrl = extractCommentUrl(event);

const hasReviewBody = reviewBody.trim().length > 0;

const taskSection = hasReviewBody
? `## Reviewer Feedback
${reviewBody}

## Instructions
- Read the PR diff and the reviewer's feedback above to understand all requested changes
- You are already checked out on the PR branch \`${branchRef}\`
- Address all the reviewer's feedback and make the necessary changes
- After making changes, commit and push them to the branch
- Respond with a concise summary of the changes you made`
: `## Instructions
- The reviewer has requested changes but did not leave a summary comment
- Use \`gh api repos/${repoFullName}/pulls/${prNumber}/reviews\` to read the review comments and understand what changes are needed
- You are already checked out on the PR branch \`${branchRef}\`
- Address all the reviewer's feedback and make the necessary changes
- After making changes, commit and push them to the branch
- Respond with a concise summary of the changes you made`;

return `You are working on a GitHub Pull Request that has received a change request review.

## Context
- **Repository**: ${repoFullName}
- **PR**: #${prNumber} - ${prTitle || "Untitled"}
- **Branch**: ${branchRef}
- **Reviewer**: @${commentAuthor}
- **Review URL**: ${commentUrl}

${taskSection}`;
}

/**
* Post a reply back to the GitHub PR comment after the session completes.
*/
Expand Down
35 changes: 26 additions & 9 deletions packages/edge-worker/src/SlackChatAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export class SlackChatAdapter
this.logger = logger ?? createLogger({ component: "SlackChatAdapter" });
}

/**
* Get the Slack bot token, falling back to process.env if the event doesn't carry one.
*
* The event's slackBotToken is set at webhook-reception time by SlackEventTransport.
* During startup transitions (e.g. switching from cloud to self-host), the token may
* not yet be in process.env when the event is created but may arrive shortly after
* via an async env update. This fallback ensures the token is picked up even if
* it was loaded into process.env after the event was created.
*/
private getSlackBotToken(event: SlackWebhookEvent): string | undefined {
return event.slackBotToken ?? process.env.SLACK_BOT_TOKEN;
}

private async getSelfBotId(token: string): Promise<string | undefined> {
if (this.selfBotId) {
return this.selfBotId;
Expand Down Expand Up @@ -99,7 +112,8 @@ Supported mrkdwn syntax:
return "";
}

if (!event.slackBotToken) {
const token = this.getSlackBotToken(event);
if (!token) {
this.logger.warn(
"Cannot fetch Slack thread context: no slackBotToken available",
);
Expand All @@ -110,12 +124,12 @@ Supported mrkdwn syntax:
const slackService = new SlackMessageService();
const [messages, selfBotId] = await Promise.all([
slackService.fetchThreadMessages({
token: event.slackBotToken,
token,
channel: event.payload.channel,
thread_ts: event.payload.thread_ts,
limit: 50,
}),
this.getSelfBotId(event.slackBotToken),
this.getSelfBotId(token),
]);

// Filter out the @mention message itself and the bot's own replies.
Expand Down Expand Up @@ -169,7 +183,8 @@ Supported mrkdwn syntax:
}
}

if (!event.slackBotToken) {
const token = this.getSlackBotToken(event);
if (!token) {
this.logger.warn("Cannot post Slack reply: no slackBotToken available");
return;
}
Expand All @@ -178,7 +193,7 @@ Supported mrkdwn syntax:
const threadTs = event.payload.thread_ts || event.payload.ts;

await new SlackMessageService().postMessage({
token: event.slackBotToken,
token,
channel: event.payload.channel,
text: summary,
thread_ts: threadTs,
Expand All @@ -196,30 +211,32 @@ Supported mrkdwn syntax:
}

async acknowledgeReceipt(event: SlackWebhookEvent): Promise<void> {
if (!event.slackBotToken) {
const token = this.getSlackBotToken(event);
if (!token) {
this.logger.warn(
"Cannot add Slack reaction: no slackBotToken available (SLACK_BOT_TOKEN env var not set)",
);
return;
}

await new SlackReactionService().addReaction({
token: event.slackBotToken,
token,
channel: event.payload.channel,
timestamp: event.payload.ts,
name: "eyes",
});
}

async notifyBusy(event: SlackWebhookEvent): Promise<void> {
if (!event.slackBotToken) {
const token = this.getSlackBotToken(event);
if (!token) {
return;
}

const threadTs = event.payload.thread_ts || event.payload.ts;

await new SlackMessageService().postMessage({
token: event.slackBotToken,
token,
channel: event.payload.channel,
text: "I'm still working on the previous request in this thread. I'll pick up your new message once I'm done.",
thread_ts: threadTs,
Expand Down
Loading
Loading