From c54e7d725fc52e460af3cb8f3bb016122bf552ef Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Wed, 20 Aug 2025 10:06:24 -0700 Subject: [PATCH] feat: Trigger new runs from pull requests (#725) * feat: Trigger new runs from pull requests * cr * cr * much better * fix * cr * unique prompts per trigger type * improve prompts * cr * cr * cr * abstract stuff --- apps/open-swe/src/constants.ts | 1 + .../manager/nodes/classify-message/prompts.ts | 4 +- .../nodes/generate-message/index.ts | 13 + .../src/graphs/programmer/nodes/open-pr.ts | 3 +- .../graphs/programmer/nodes/take-action.ts | 13 + apps/open-swe/src/routes/app.ts | 4 +- .../routes/github/get-pr-context.int.test.ts | 62 ++++ .../src/routes/github/issue-labeled.ts | 117 ++++++ .../src/routes/github/issue-webhook.ts | 255 ------------- .../routes/github/pr-webhook-handler-base.ts | 159 ++++++++ apps/open-swe/src/routes/github/prompts.ts | 344 ++++++++++++++++++ .../src/routes/github/pull-request-comment.ts | 124 +++++++ .../github/pull-request-review-comment.ts | 120 ++++++ .../src/routes/github/pull-request-review.ts | 114 ++++++ apps/open-swe/src/routes/github/types.ts | 68 ++++ .../src/routes/github/unified-webhook.ts | 93 +++++ apps/open-swe/src/routes/github/utils.ts | 256 +++++++++++++ .../src/routes/github/webhook-handler-base.ts | 155 ++++++++ .../src/routes/github/webhook-messages.ts | 76 ++++ .../src/tools/reply-to-review-comment.ts | 134 +++++++ apps/open-swe/src/utils/config.ts | 2 +- apps/open-swe/src/utils/github/api.ts | 140 +++++++ apps/open-swe/src/utils/github/git.ts | 4 +- .../src/utils/github/issue-messages.ts | 12 +- apps/open-swe/src/utils/github/types.ts | 6 + packages/shared/src/open-swe/tools.ts | 70 ++++ packages/shared/src/open-swe/types.ts | 12 + 27 files changed, 2096 insertions(+), 265 deletions(-) create mode 100644 apps/open-swe/src/routes/github/get-pr-context.int.test.ts create mode 100644 apps/open-swe/src/routes/github/issue-labeled.ts delete mode 100644 apps/open-swe/src/routes/github/issue-webhook.ts create mode 100644 apps/open-swe/src/routes/github/pr-webhook-handler-base.ts create mode 100644 apps/open-swe/src/routes/github/prompts.ts create mode 100644 apps/open-swe/src/routes/github/pull-request-comment.ts create mode 100644 apps/open-swe/src/routes/github/pull-request-review-comment.ts create mode 100644 apps/open-swe/src/routes/github/pull-request-review.ts create mode 100644 apps/open-swe/src/routes/github/types.ts create mode 100644 apps/open-swe/src/routes/github/unified-webhook.ts create mode 100644 apps/open-swe/src/routes/github/utils.ts create mode 100644 apps/open-swe/src/routes/github/webhook-handler-base.ts create mode 100644 apps/open-swe/src/routes/github/webhook-messages.ts create mode 100644 apps/open-swe/src/tools/reply-to-review-comment.ts diff --git a/apps/open-swe/src/constants.ts b/apps/open-swe/src/constants.ts index dd89268c..83b94258 100644 --- a/apps/open-swe/src/constants.ts +++ b/apps/open-swe/src/constants.ts @@ -26,4 +26,5 @@ export const LANGGRAPH_USER_PERMISSIONS = [ export enum RequestSource { GITHUB_ISSUE_WEBHOOK = "github_issue_webhook", + GITHUB_PULL_REQUEST_WEBHOOK = "github_pull_request_webhook", } diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts index 3371584d..0d210165 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts @@ -43,8 +43,8 @@ Ensure your response is clear, and concise. Although you're only supposed to classify & respond to the latest message, this does not mean you should look at it in isolation. You should consider the conversation history as a whole, and the current status of your two AI assistants (programmer and planner) to determine how to respond & route the user's new message. -If the source is from a '${RequestSource.GITHUB_ISSUE_WEBHOOK}', you should ALWAYS classify it as a full request which should be routed to the planner. -The instances where the source will be '${RequestSource.GITHUB_ISSUE_WEBHOOK}' are when the user labels a GitHub issue as a task to be completed by the AI coding agent system. +If the source is from a '${RequestSource.GITHUB_ISSUE_WEBHOOK}', '${RequestSource.GITHUB_PULL_REQUEST_WEBHOOK}', you should ALWAYS classify it as a full request which should be routed to the planner. +The instances where the source will be a GitHub webhook are when the user takes some action in GitHub which triggers a webhook, such as labeling an issue or pull request, or tagging you to review a pull request. # Context Although it's not shown here, you do have access to the full repository contents the user is referencing. Because of this, you should always assume you'll have access to any/all files or folders the user is referencing. diff --git a/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts b/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts index 97267d21..6bdef56c 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts @@ -60,6 +60,12 @@ import { } from "@langchain/core/messages"; import { BindToolsInput } from "@langchain/core/language_models/chat_models"; import { shouldCreateIssue } from "../../../../utils/should-create-issue.js"; +import { + createReplyToReviewCommentTool, + createReplyToCommentTool, + shouldIncludeReviewCommentTool, + createReplyToReviewTool, +} from "../../../../tools/reply-to-review-comment.js"; const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); @@ -184,6 +190,13 @@ async function createToolsAndPrompt( createMarkTaskCompletedToolFields(), createSearchDocumentForTool(state, config), createWriteDefaultTsConfigTool(state, config), + ...(shouldIncludeReviewCommentTool(state, config) + ? [ + createReplyToReviewCommentTool(state, config), + createReplyToCommentTool(state, config), + createReplyToReviewTool(state, config), + ] + : []), ...mcpTools, ]; diff --git a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts index 5068c987..b49233c1 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts @@ -210,7 +210,8 @@ export async function openPullRequest( | GitHubPullRequestUpdate | null = null; - const prBody = `${shouldCreateIssue(config) ? `Fixes #${state.githubIssueId}` : ""}${userLogin ? `\n\nOwner: @${userLogin}` : ""}\n\n${body}`; + const reviewPullNumber = config.configurable?.reviewPullNumber; + const prBody = `${shouldCreateIssue(config) ? `Fixes #${state.githubIssueId}` : ""}${reviewPullNumber ? `\n\nTriggered from pull request: #${reviewPullNumber}` : ""}${userLogin ? `\n\nOwner: @${userLogin}` : ""}\n\n${body}`; if (!prForTask) { // No PR created yet. Shouldn't be possible, but we have a condition here anyway diff --git a/apps/open-swe/src/graphs/programmer/nodes/take-action.ts b/apps/open-swe/src/graphs/programmer/nodes/take-action.ts index ece57e39..37a18478 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/take-action.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/take-action.ts @@ -41,6 +41,12 @@ import { getActiveTask } from "@open-swe/shared/open-swe/tasks"; import { createPullRequestToolCallMessage } from "../../../utils/message/create-pr-message.js"; import { filterUnsafeCommands } from "../../../utils/command-evaluation.js"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; +import { + createReplyToCommentTool, + createReplyToReviewCommentTool, + createReplyToReviewTool, + shouldIncludeReviewCommentTool, +} from "../../../tools/reply-to-review-comment.js"; const logger = createLogger(LogLevel.INFO, "TakeAction"); @@ -83,6 +89,13 @@ export async function takeAction( getURLContentTool, searchDocumentForTool, writeDefaultTsConfigTool, + ...(shouldIncludeReviewCommentTool(state, config) + ? [ + createReplyToReviewCommentTool(state, config), + createReplyToCommentTool(state, config), + createReplyToReviewTool(state, config), + ] + : []), ...mcpTools, ]; const toolsMap = Object.fromEntries( diff --git a/apps/open-swe/src/routes/app.ts b/apps/open-swe/src/routes/app.ts index a0ebd0b6..e55b28be 100644 --- a/apps/open-swe/src/routes/app.ts +++ b/apps/open-swe/src/routes/app.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { issueWebhookHandler } from "./github/issue-webhook.js"; +import { unifiedWebhookHandler } from "./github/unified-webhook.js"; export const app = new Hono(); -app.post("/webhooks/github", issueWebhookHandler); +app.post("/webhooks/github", unifiedWebhookHandler); diff --git a/apps/open-swe/src/routes/github/get-pr-context.int.test.ts b/apps/open-swe/src/routes/github/get-pr-context.int.test.ts new file mode 100644 index 00000000..e7df2fd0 --- /dev/null +++ b/apps/open-swe/src/routes/github/get-pr-context.int.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "@jest/globals"; +import { Octokit } from "@octokit/core"; +import { getPrContext } from "./utils.js"; + +/** + * Integration test for getPrContext against a real GitHub PR. + * Requires process.env.GITHUB_PAT_PR_REVIEW_TESTING to be set to a GitHub Personal Access Token + * with issue and PR read permissions for a repo. + */ +describe("getPrContext integration - langchain-ai/open-swe-dev#725", () => { + it("separates PR comments and review comments; finds expected messages", async () => { + const token = process.env.GITHUB_PAT_PR_REVIEW_TESTING; + if (!token) { + return; + } + + const octokit = new Octokit({ auth: token }); + + const owner = "langchain-ai"; + const repo = "open-swe-dev"; + const prNumber = 725; + + const { prComments, reviews } = await getPrContext(octokit, { + owner, + repo, + prNumber, + linkedIssueNumbers: [], + }); + + expect(prComments).toHaveLength(2); + + // PR-level comment (issue comment) + const hasNormalComment = prComments.some( + (c) => (c.body ?? "").trim() === "this is a normal comment", + ); + expect(hasNormalComment).toBe(true); + + expect(reviews).toHaveLength(1); + + // Review with CHANGES_REQUESTED and expected review body + const changesRequestedReview = reviews.find( + (r) => + (r.state ?? "").toUpperCase() === "CHANGES_REQUESTED" && + (r.body ?? "").trim() === "this is a review message", + ); + expect(changesRequestedReview).toBeDefined(); + + // Nested review comment + const allReviewComments = reviews.flatMap((r) => r.reviewComments ?? []); + expect(allReviewComments).toHaveLength(1); + const hasReviewComment = allReviewComments.some( + (rc) => (rc.body ?? "").trim() === "this is a review comment", + ); + expect(hasReviewComment).toBe(true); + + // Ensure review comment is not duplicated in PR comments + const prContainsReviewComment = prComments.some( + (c) => (c.body ?? "").trim() === "this is a review comment", + ); + expect(prContainsReviewComment).toBe(false); + }); +}); diff --git a/apps/open-swe/src/routes/github/issue-labeled.ts b/apps/open-swe/src/routes/github/issue-labeled.ts new file mode 100644 index 00000000..7b0911c8 --- /dev/null +++ b/apps/open-swe/src/routes/github/issue-labeled.ts @@ -0,0 +1,117 @@ +import { WebhookHandlerBase } from "./webhook-handler-base.js"; +import { + getOpenSWEAutoAcceptLabel, + getOpenSWELabel, + getOpenSWEMaxLabel, + getOpenSWEMaxAutoAcceptLabel, +} from "../../utils/github/label.js"; +import { RequestSource } from "../../constants.js"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; + +class IssueWebhookHandler extends WebhookHandlerBase { + constructor() { + super("GitHubIssueHandler"); + } + + async handleIssueLabeled(payload: any) { + if (!process.env.SECRETS_ENCRYPTION_KEY) { + throw new Error( + "SECRETS_ENCRYPTION_KEY environment variable is required", + ); + } + + const validOpenSWELabels = [ + getOpenSWELabel(), + getOpenSWEAutoAcceptLabel(), + getOpenSWEMaxLabel(), + getOpenSWEMaxAutoAcceptLabel(), + ]; + + if ( + !payload.label?.name || + !validOpenSWELabels.some((l) => l === payload.label?.name) + ) { + return; + } + + const isAutoAcceptLabel = + payload.label.name === getOpenSWEAutoAcceptLabel() || + payload.label.name === getOpenSWEMaxAutoAcceptLabel(); + + const isMaxLabel = + payload.label.name === getOpenSWEMaxLabel() || + payload.label.name === getOpenSWEMaxAutoAcceptLabel(); + + this.logger.info( + `'${payload.label.name}' label added to issue #${payload.issue.number}`, + { + isAutoAcceptLabel, + isMaxLabel, + }, + ); + + try { + const context = await this.setupWebhookContext(payload); + if (!context) { + return; + } + + const issueData = { + issueNumber: payload.issue.number, + issueTitle: payload.issue.title, + issueBody: payload.issue.body || "", + }; + + const runInput = { + messages: [ + this.createHumanMessage( + `**${issueData.issueTitle}**\n\n${issueData.issueBody}`, + RequestSource.GITHUB_ISSUE_WEBHOOK, + { + isOriginalIssue: true, + githubIssueId: issueData.issueNumber, + }, + ), + ], + githubIssueId: issueData.issueNumber, + targetRepository: { + owner: context.owner, + repo: context.repo, + }, + autoAcceptPlan: isAutoAcceptLabel, + }; + + // Create config object with Claude Opus 4.1 model configuration for max labels + const configurable: Partial = isMaxLabel + ? { + plannerModelName: "anthropic:claude-opus-4-1", + programmerModelName: "anthropic:claude-opus-4-1", + } + : {}; + + const { runId, threadId } = await this.createRun(context, { + runInput, + configurable, + }); + + await this.createComment( + context, + { + issueNumber: issueData.issueNumber, + message: + "🤖 Open SWE has been triggered for this issue. Processing...", + }, + runId, + threadId, + ); + } catch (error) { + this.handleError(error, "issue webhook"); + } + } +} + +const issueHandler = new IssueWebhookHandler(); + +export async function handleIssueLabeled(payload: any) { + return issueHandler.handleIssueLabeled(payload); +} diff --git a/apps/open-swe/src/routes/github/issue-webhook.ts b/apps/open-swe/src/routes/github/issue-webhook.ts deleted file mode 100644 index 1941151c..00000000 --- a/apps/open-swe/src/routes/github/issue-webhook.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; -import { Context } from "hono"; -import { BlankEnv, BlankInput } from "hono/types"; -import { createLogger, LogLevel } from "../../utils/logger.js"; -import { GitHubApp } from "../../utils/github-app.js"; -import { Webhooks } from "@octokit/webhooks"; -import { createLangGraphClient } from "../../utils/langgraph-client.js"; -import { - GITHUB_INSTALLATION_ID, - GITHUB_INSTALLATION_NAME, - GITHUB_INSTALLATION_TOKEN_COOKIE, - GITHUB_USER_ID_HEADER, - GITHUB_USER_LOGIN_HEADER, - MANAGER_GRAPH_ID, - OPEN_SWE_STREAM_MODE, -} from "@open-swe/shared/constants"; -import { encryptSecret } from "@open-swe/shared/crypto"; -import { HumanMessage } from "@langchain/core/messages"; -import { - getOpenSWEAutoAcceptLabel, - getOpenSWELabel, - getOpenSWEMaxLabel, - getOpenSWEMaxAutoAcceptLabel, -} from "../../utils/github/label.js"; -import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; -import { RequestSource } from "../../constants.js"; -import { isAllowedUser } from "@open-swe/shared/github/allowed-users"; -import { getOpenSweAppUrl } from "../../utils/url-helpers.js"; -import { StreamMode } from "@langchain/langgraph-sdk"; - -const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook"); - -const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!; - -const githubApp = new GitHubApp(); - -const webhooks = new Webhooks({ - secret: GITHUB_WEBHOOK_SECRET, -}); - -const getPayload = (body: string): Record | null => { - try { - const payload = JSON.parse(body); - return payload; - } catch { - return null; - } -}; - -const createDevMetadataComment = (runId: string, threadId: string) => { - return `
- Dev Metadata - ${JSON.stringify( - { - runId, - threadId, - }, - null, - 2, - )} -
`; -}; - -const getHeaders = ( - c: Context, -): { - id: string; - name: string; - installationId: string; - targetType: string; -} | null => { - const headers = c.req.header(); - const webhookId = headers["x-github-delivery"] || ""; - const webhookEvent = headers["x-github-event"] || ""; - const installationId = headers["x-github-hook-installation-target-id"] || ""; - const targetType = headers["x-github-hook-installation-target-type"] || ""; - if (!webhookId || !webhookEvent || !installationId || !targetType) { - return null; - } - return { id: webhookId, name: webhookEvent, installationId, targetType }; -}; - -webhooks.on("issues.labeled", async ({ payload }) => { - if (!process.env.SECRETS_ENCRYPTION_KEY) { - throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required"); - } - const validOpenSWELabels = [ - getOpenSWELabel(), - getOpenSWEAutoAcceptLabel(), - getOpenSWEMaxLabel(), - getOpenSWEMaxAutoAcceptLabel(), - ]; - if ( - !payload.label?.name || - !validOpenSWELabels.some((l) => l === payload.label?.name) - ) { - return; - } - const isAutoAcceptLabel = - payload.label.name === getOpenSWEAutoAcceptLabel() || - payload.label.name === getOpenSWEMaxAutoAcceptLabel(); - - const isMaxLabel = - payload.label.name === getOpenSWEMaxLabel() || - payload.label.name === getOpenSWEMaxAutoAcceptLabel(); - - logger.info( - `'${payload.label.name}' label added to issue #${payload.issue.number}`, - { - isAutoAcceptLabel, - isMaxLabel, - }, - ); - - try { - // Get installation ID from the webhook payload - const installationId = payload.installation?.id; - - if (!installationId) { - logger.error("No installation ID found in webhook payload"); - return; - } - - const [octokit, { token }] = await Promise.all([ - githubApp.getInstallationOctokit(installationId), - githubApp.getInstallationAccessToken(installationId), - ]); - const issueData = { - owner: payload.repository.owner.login, - repo: payload.repository.name, - issueNumber: payload.issue.number, - issueTitle: payload.issue.title, - issueBody: payload.issue.body || "", - userId: payload.sender.id, - userLogin: payload.sender.login, - }; - - if (!isAllowedUser(issueData.userLogin)) { - logger.error("User is not a member of allowed orgs", { - username: issueData.userLogin, - }); - return; - } - - const langGraphClient = createLangGraphClient({ - defaultHeaders: { - [GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret( - token, - process.env.SECRETS_ENCRYPTION_KEY, - ), - [GITHUB_INSTALLATION_NAME]: issueData.owner, - [GITHUB_USER_ID_HEADER]: issueData.userId.toString(), - [GITHUB_USER_LOGIN_HEADER]: issueData.userLogin, - [GITHUB_INSTALLATION_ID]: installationId.toString(), - }, - }); - - const threadId = uuidv4(); - const runInput: ManagerGraphUpdate = { - messages: [ - new HumanMessage({ - id: uuidv4(), - content: `**${issueData.issueTitle}**\n\n${issueData.issueBody}`, - additional_kwargs: { - isOriginalIssue: true, - githubIssueId: issueData.issueNumber, - requestSource: RequestSource.GITHUB_ISSUE_WEBHOOK, - }, - }), - ], - githubIssueId: issueData.issueNumber, - targetRepository: { - owner: issueData.owner, - repo: issueData.repo, - }, - autoAcceptPlan: isAutoAcceptLabel, - }; - // Create config object with Claude Opus 4.1 model configuration for max labels - const config: Record = { - recursion_limit: 400, - }; - - if (isMaxLabel) { - config.configurable = { - plannerModelName: "anthropic:claude-opus-4-1", - programmerModelName: "anthropic:claude-opus-4-1", - }; - } - - const run = await langGraphClient.runs.create(threadId, MANAGER_GRAPH_ID, { - input: runInput, - config, - ifNotExists: "create", - streamResumable: true, - streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], - }); - - logger.info("Created new run from GitHub issue.", { - threadId, - runId: run.run_id, - issueNumber: issueData.issueNumber, - owner: issueData.owner, - repo: issueData.repo, - userId: issueData.userId, - userLogin: issueData.userLogin, - autoAcceptPlan: isAutoAcceptLabel, - }); - - logger.info("Creating comment..."); - const appUrl = getOpenSweAppUrl(threadId); - const appUrlCommentText = appUrl - ? `View run in Open SWE [here](${appUrl}) (this URL will only work for @${issueData.userLogin})` - : ""; - await octokit.request( - "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", - { - owner: issueData.owner, - repo: issueData.repo, - issue_number: issueData.issueNumber, - body: `🤖 Open SWE has been triggered for this issue. Processing...\n\n${appUrlCommentText}\n\n${createDevMetadataComment(run.run_id, threadId)}`, - }, - ); - } catch (error) { - logger.error("Error processing webhook:", error); - } -}); - -export async function issueWebhookHandler( - c: Context, -) { - const payload = getPayload(await c.req.text()); - if (!payload) { - logger.error("Missing payload"); - return c.json({ error: "Missing payload" }, { status: 400 }); - } - - const eventHeaders = getHeaders(c); - if (!eventHeaders) { - logger.error("Missing webhook headers"); - return c.json({ error: "Missing webhook headers" }, { status: 400 }); - } - - try { - await webhooks.receive({ - id: eventHeaders.id, - name: eventHeaders.name as any, - payload, - }); - - return c.json({ received: true }); - } catch (error) { - logger.error("Webhook error:", error); - return c.json({ error: "Webhook processing failed" }, { status: 400 }); - } -} diff --git a/apps/open-swe/src/routes/github/pr-webhook-handler-base.ts b/apps/open-swe/src/routes/github/pr-webhook-handler-base.ts new file mode 100644 index 00000000..058ac68c --- /dev/null +++ b/apps/open-swe/src/routes/github/pr-webhook-handler-base.ts @@ -0,0 +1,159 @@ +import { + WebhookHandlerBase, + WebhookHandlerContext, +} from "./webhook-handler-base.js"; +import { RequestSource } from "../../constants.js"; +import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; +import { + mentionsOpenSWE, + extractLinkedIssues, + getPrContext, + convertPRPayloadToPullRequestObj, +} from "./utils.js"; +import { + PullRequestReviewTriggerData, + SimpleIssue, + SimplePullRequestComment, + SimplePullRequestReview, + SimpleTriggerComment, +} from "./types.js"; +import { GitHubPullRequestGet } from "../../utils/github/types.js"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; + +export interface PRWebhookContext extends WebhookHandlerContext { + prNumber: number; +} + +export abstract class PRWebhookHandlerBase extends WebhookHandlerBase { + /** + * Validates that the content mentions @open-swe + */ + protected validateOpenSWEMention( + content: string, + logContext: string, + ): boolean { + if (!mentionsOpenSWE(content)) { + this.logger.info(`${logContext} does not mention @open-swe, skipping`); + return false; + } + return true; + } + + /** + * Sets up PR-specific webhook context + */ + protected async setupPRWebhookContext( + payload: any, + ): Promise { + const baseContext = await this.setupWebhookContext(payload); + if (!baseContext) { + return null; + } + + const prNumber = payload.pull_request?.number || payload.issue?.number; + if (!prNumber) { + this.logger.error("No PR number found in webhook payload"); + return null; + } + + return { + ...baseContext, + prNumber, + }; + } + + /** + * Fetches PR context including reviews, comments, and linked issues + */ + protected async fetchPRContext( + context: PRWebhookContext, + pullRequestBody: string, + ): Promise<{ + prComments: SimplePullRequestComment[]; + reviews: SimplePullRequestReview[]; + linkedIssues: SimpleIssue[]; + }> { + return await getPrContext(context.octokit, { + owner: context.owner, + repo: context.repo, + prNumber: context.prNumber, + linkedIssueNumbers: extractLinkedIssues(pullRequestBody || ""), + }); + } + + /** + * Creates PR trigger data structure + */ + protected createPRTriggerData( + pullRequest: GitHubPullRequestGet, + prNumber: number, + triggerComment: SimpleTriggerComment, + prComments: SimplePullRequestComment[], + reviews: SimplePullRequestReview[], + linkedIssues: SimpleIssue[], + repository: { owner: string; name: string }, + ): PullRequestReviewTriggerData { + return { + pullRequest: convertPRPayloadToPullRequestObj(pullRequest, prNumber), + triggerComment, + prComments, + reviews, + linkedIssues, + repository, + }; + } + + /** + * Creates a standard PR run input + */ + protected createPRRunInput( + prompt: string, + context: PRWebhookContext, + pullRequest: GitHubPullRequestGet, + ): ManagerGraphUpdate { + return { + messages: [ + this.createHumanMessage( + prompt, + RequestSource.GITHUB_PULL_REQUEST_WEBHOOK, + ), + ], + targetRepository: { + owner: context.owner, + repo: context.repo, + branch: pullRequest.head.ref, + }, + autoAcceptPlan: true, + }; + } + + /** + * Creates standard PR run configuration + */ + protected createPRRunConfiguration( + context: PRWebhookContext, + ): Partial { + return { + shouldCreateIssue: false, + reviewPullNumber: context.prNumber, + }; + } + + /** + * Abstract method for creating the prompt - each handler implements its own + */ + protected abstract createPrompt(prData: PullRequestReviewTriggerData): string; + + /** + * Abstract method for creating the comment message - each handler implements its own + */ + protected abstract createCommentMessage(linkToTrigger: string): string; + + /** + * Abstract method for creating the link to the trigger - each handler implements its own + */ + protected abstract createTriggerLink( + context: PRWebhookContext, + triggerId: number | string, + ): string; +} diff --git a/apps/open-swe/src/routes/github/prompts.ts b/apps/open-swe/src/routes/github/prompts.ts new file mode 100644 index 00000000..2eb66600 --- /dev/null +++ b/apps/open-swe/src/routes/github/prompts.ts @@ -0,0 +1,344 @@ +import { + createReplyToCommentToolFields, + createReplyToReviewCommentToolFields, + createReplyToReviewToolFields, +} from "@open-swe/shared/open-swe/tools"; +import { + PullRequestReviewTriggerData, + SimpleIssue, + SimplePullRequest, + SimplePullRequestComment, + SimplePullRequestReview, + SimpleTriggerComment, +} from "./types.js"; + +// For PR review triggers +const PR_REVIEW_TRIGGER_PROMPT = ` +You're tasked with resolving all of the relevant comments/reviews which were left on this pull request. + +For each comment, determine whether or not it needs a code change, and if so update the code to properly resolve the comment. + IMPORTANT: Remember that some comments might already be resolved, so don't blindly make changes based on the comments alone. You mainly care about the actual PR review which was left on the PR. +For comments which do require code changes, you should implement the changes in the simplest way possible. +Ensure they're implemented to properly resolve the comment. Do not make any changes which are not directly related to resolving the comment. +Do not leave comments in your code about the review, or changes you're making. +After making a code change ensure you reply to the review comment which requested the change using the '${createReplyToReviewCommentToolFields().name}' tool. This message should be very short and to the point. + +For comments which do not require code changes, you should either reply to the comment using the '${createReplyToReviewCommentToolFields().name}' tool, or ignore the comment if it's a no-op. + +Finally, when you've finished resolving the entire review, you should reply to the original review using the '${createReplyToReviewToolFields().name}' tool. + +NOTE: This is different from replying to a normal comment, and different from replying to review comments. Ensure you use each tool appropriately. + +The changes you make will be put into a pull request which is set to be merged into the branch the review was left on. This will happen automatically for you. + + + +The context you're provided with to resolve the PR review is as follows: +- The pull request data (title, body, author, etc.). This may include context about the PR, why it was created, information about the changes, etc. +- The issue(s) that the PR will close when merged. Ensure you read these issue titles/descriptions so you have an idea as to the purpose of the PR. +- The comments left on the PR. These are important as they may include context about the PR, or feedback on the code which you should resolve. + IMPORTANT: Keep in mind that some of these comments may already be resolved, so don't blindly make changes based on the comments alone. You mainly care about the actual PR review which was left on the PR. +- The reviews left on the PR. You're provided with all of the PR reviews left on this pull request. Each review may include a main review message, review comments, and a state (e.g. "approved", "changes requested"). You should focus on the latest review if there are multiple. + - IMPORTANT: The comments on a review may reference specific lines of code. You should pay close attention to these comments and ensure you implement the changes in the simplest way possible. + +With all of this context in mind, ensure you focus on the content inside the tag. This is the review you were tagged in, and it is what kicked off this process. Ensure this is the only review you're focused on, but still take into account the other comments/reviews for context. + + + +Here is the data on the pull request you're resolving the review for: + +{PR_DATA} + + + + +Here are the issues which will be closed when this pull request is merged. Ensure you read over the issue titles/descriptions so you have an idea as to the purpose of the PR. + +{LINKED_ISSUES} + + + + +Here are all of the comments (if any) which were left on the pull request. + +{PR_COMMENTS} + + + + +Here are all of the reviews which were left on the pull request. +If there are multiple, you should prioritize the reviews which are still "active" (e.g. changed requested, approved, or commented). However, still keep in mind the previous reviews for important context. + +{PR_REVIEWS} + + + + +Here is the review you were tagged in. Ensure you focus on resolving whatever request was made in the review. + +{TRIGGER_COMMENT} + + + +Given all of this context, please resolve the PR review comments in the simplest ways possible. You are only to make the code changes as requested in the review. A pull request will be automatically created for you with these changes that points to the original branch the review was left on. +You're already checked out on a new branch which is based on the original branch the review was left on. You should make all your changes on this branch. + +IMPORTANT: The comments in the reviews should take precedence over the comments on the linked issue(s), or the body of the pull request/issue. Your main goal is to resolve all of the relevant review comments not yet addressed, in the simplest and most direct way possible.`; + +// For PR review comment triggers +const PR_REVIEW_COMMENT_TRIGGER_PROMPT = ` +You're tasked with resolving the pull request review comment which was left on this PR, and you (open-swe) were tagged in. + +For the review comment, determine whether or not it needs a code change, and if so update the code to properly resolve the comment. + IMPORTANT: Remember that some comments might already be resolved, so don't blindly make changes based on the comments alone. You mainly care about the actual PR review which was left on the PR. +If the review comment does require code changes, you should implement the changes in the simplest way possible. +Ensure they're implemented to properly resolve the comment. Do not make any changes which are not directly related to resolving the comment. +Do not leave comments in your code about the review, or changes you're making. +After making a code change ensure you reply to the review comment which requested the change using the '${createReplyToReviewCommentToolFields().name}' tool. This message should be very short and to the point. + +If the review comment does not require code changes, you should either reply to the comment using the '${createReplyToReviewCommentToolFields().name}' tool, or ignore the comment if it's a no-op. + +The changes you make will be put into a pull request which is set to be merged into the branch the review was left on. This will happen automatically for you. + +REMINDER: You were tagged in a review comment. There may be many review comments which you're tagged in, so focus on the latest review comment. However you should still keep in mind all other comments as they may have useful context. + + + +The context you're provided with to resolve the PR review comment is as follows: +- The pull request data (title, body, author, etc.). This may include context about the PR, why it was created, information about the changes, etc. +- The issue(s) that the PR will close when merged. Ensure you read these issue titles/descriptions so you have an idea as to the purpose of the PR. +- The comments left on the PR. These are important as they may include context about the PR, or feedback on the code which you should resolve. + IMPORTANT: Keep in mind that some of these comments may already be resolved, so don't blindly make changes based on the comments alone. You mainly care about the actual PR review which was left on the PR. +- The reviews left on the PR. You're provided with all of the PR reviews left on this pull request. Each review may include a main review message, review comments, and a state (e.g. "approved", "changes requested"). You should focus on the latest review you were tagged in. + - IMPORTANT: The comments on a review may reference specific lines of code. You should pay close attention to these comments and ensure you implement the changes in the simplest way possible. + +With all of this context in mind, ensure you focus on the content inside the tag. This is the comment you were tagged in, and it is what kicked off this process. Ensure this is the only comment you're focused on, but still take into account the other comments/reviews for context. + + + +Here is the data on the pull request you're resolving the review for: + +{PR_DATA} + + + + +Here are the issues which will be closed when this pull request is merged. Ensure you read over the issue titles/descriptions so you have an idea as to the purpose of the PR. + +{LINKED_ISSUES} + + + + +Here are all of the comments (if any) which were left on the pull request. + +{PR_COMMENTS} + + + + +Here are all of the reviews which were left on the pull request. +If there are multiple, you should prioritize the reviews which are still "active" (e.g. changed requested, approved, or commented). However, still keep in mind the previous reviews for important context. +Ensure you focus on the latest review comment which you were tagged in. + +{PR_REVIEWS} + + + + +Here is the review comment you were tagged in. Ensure you focus on resolving whatever request was made in the comment. + +{TRIGGER_COMMENT} + + + +Given all of this context, please resolve the latest PR review comment you were tagged in, in the simplest way possible. You are only to make the code changes as requested in the review comment. A pull request will be automatically created for you with these changes that points to the original branch the review was left on. +You're already checked out on a new branch which is based on the original branch the review was left on. You should make all your changes on this branch. + +IMPORTANT: The review comment should take precedence over the comments on the linked issue(s), or the body of the pull request/issue. Your main goal is to resolve the review comment you were just tagged in, in the simplest and most direct way possible.`; + +// For PR comment triggers +const PR_COMMENT_TRIGGER_PROMPT = ` +The user has tagged you (open-swe) in a comment on this pull request. Your task is to resolve their comment in the simplest way possible. + +Determine whether or not the comment requires a code change, and if so update the code to properly resolve the comment. +After making a code change ensure you reply to the comment which requested the change using the '${createReplyToCommentToolFields().name}' tool. This message should be very short and to the point. +For comments which do require code changes, you should implement the changes in the simplest way possible. +Ensure they're implemented to properly resolve the comment. Do not make any changes which are not directly related to resolving the comment. +Do not leave comments in your code about the review, or changes you're making. + +For comments which do not require code changes, you should either reply to the comment using the '${createReplyToCommentToolFields().name}' tool, or ignore the comment if it's a no-op. + +The changes you make will be put into a pull request which is set to be merged into the branch the comment was left on. This will happen automatically for you. + + + +The context you're provided with to resolve the PR review comment is as follows: +- The pull request data (title, body, author, etc.). This may include context about the PR, why it was created, information about the changes, etc. +- The issue(s) that the PR will close when merged. Ensure you read these issue titles/descriptions so you have an idea as to the purpose of the PR. +- The reviews left on the PR. You're provided with all of the PR reviews left on this pull request. Each review may include a main review message, review comments, and a state (e.g. "approved", "changes requested"). +- The comments left on the PR. These are important as they may include context about the PR, or feedback on the code which you should resolve. + IMPORTANT: Keep in mind that some of these comments may already be resolved, so don't blindly make changes based on the comments alone. You mainly care about the latest comment you were tagged in. + +With all of this context in mind, ensure you focus on the content inside the tag. This is the comment you were tagged in, and it is what kicked off this process. Ensure this is the only comment you're focused on, but still take into account the other comments/reviews for context. + + + +Here is the data on the pull request you're resolving the review for: + +{PR_DATA} + + + + +Here are the issues which will be closed when this pull request is merged. Ensure you read over the issue titles/descriptions so you have an idea as to the purpose of the PR. + +{LINKED_ISSUES} + + + + +Here are all of the reviews which were left on the pull request (if any). +If there are multiple, you should prioritize the reviews which are still "active" (e.g. changed requested, approved, or commented). However, still keep in mind the previous reviews for important context. + +{PR_REVIEWS} + + + + + +Here are all of the comments which were left on the pull request. Ensure you focus on the latest comment below which you were tagged in. + +{PR_COMMENTS} + + + + +Here is the comment you were tagged in. Ensure you focus on resolving whatever request was made in the comment. + +{TRIGGER_COMMENT} + + + +Given all of this context, please resolve the comment you were tagged in, in the simplest ways possible. You are only to make the code changes as requested in the comment. A pull request will be automatically created for you with these changes that points to the original branch the comment was left on. +You're already checked out on a new branch which is based on the original branch the comment was left on. You should make all your changes on this branch. + +IMPORTANT: The comment should take precedence over the comments on the linked issue(s), or the body of the pull request/issue. Your main goal is to resolve the comment you were tagged in, in the simplest and most direct way possible.`; + +function formatLinkedIssuesPrompt(issues: SimpleIssue[]): string { + if (!issues.length) { + return "No linked issues"; + } + + return issues + .map( + (issue) => ` + ${issue.state} + ${issue.title} + ${issue.body ?? "No body"} + +`, + ) + .join("\n"); +} + +function formatPRCommentsPrompt(comments: SimplePullRequestComment[]): string { + if (!comments.length) { + return "No comments"; + } + + return comments + .map( + (comment) => ` + ${comment.author} + ${comment.body} + +`, + ) + .join("\n"); +} + +function formatPRReviewsPrompt(reviews: SimplePullRequestReview[]): string { + if (!reviews.length) { + return "No reviews"; + } + + return reviews + .map( + (review) => ` + ${review.author} + ${review.body ?? "No review body"} + ${review.state} + + ${review.reviewComments.map( + (comment) => ` + ${comment.body ?? "No review comment body"} + ${comment.path} + ${comment.line} + + ${comment.diff_hunk} + + `, + )} + + +`, + ) + .join("\n"); +} + +function formatPRDataPrompt(prData: SimplePullRequest): string { + return `\n${prData.title} +${prData.body} +${prData.author} +${prData.state} + +${prData.head.ref}`; +} + +function formatTriggerComment(comment: SimpleTriggerComment): string { + return `${comment.author} +${comment.body} +${comment.path ? `${comment.path}` : ""} +${comment.line ? `${comment.line}` : ""} +${comment.diff_hunk ? `${comment.diff_hunk}` : ""}`; +} + +export function createPromptFromPRReviewTrigger( + data: PullRequestReviewTriggerData, +): string { + return PR_REVIEW_TRIGGER_PROMPT.replace( + "{PR_DATA}", + formatPRDataPrompt(data.pullRequest), + ) + .replace("{LINKED_ISSUES}", formatLinkedIssuesPrompt(data.linkedIssues)) + .replace("{PR_COMMENTS}", formatPRCommentsPrompt(data.prComments)) + .replace("{PR_REVIEWS}", formatPRReviewsPrompt(data.reviews)) + .replace("{TRIGGER_COMMENT}", formatTriggerComment(data.triggerComment)); +} + +export function createPromptFromPRReviewCommentTrigger( + data: PullRequestReviewTriggerData, +): string { + return PR_REVIEW_COMMENT_TRIGGER_PROMPT.replace( + "{PR_DATA}", + formatPRDataPrompt(data.pullRequest), + ) + .replace("{LINKED_ISSUES}", formatLinkedIssuesPrompt(data.linkedIssues)) + .replace("{PR_COMMENTS}", formatPRCommentsPrompt(data.prComments)) + .replace("{PR_REVIEWS}", formatPRReviewsPrompt(data.reviews)) + .replace("{TRIGGER_COMMENT}", formatTriggerComment(data.triggerComment)); +} + +export function createPromptFromPRCommentTrigger( + data: PullRequestReviewTriggerData, +): string { + return PR_COMMENT_TRIGGER_PROMPT.replace( + "{PR_DATA}", + formatPRDataPrompt(data.pullRequest), + ) + .replace("{LINKED_ISSUES}", formatLinkedIssuesPrompt(data.linkedIssues)) + .replace("{PR_COMMENTS}", formatPRCommentsPrompt(data.prComments)) + .replace("{PR_REVIEWS}", formatPRReviewsPrompt(data.reviews)) + .replace("{TRIGGER_COMMENT}", formatTriggerComment(data.triggerComment)); +} diff --git a/apps/open-swe/src/routes/github/pull-request-comment.ts b/apps/open-swe/src/routes/github/pull-request-comment.ts new file mode 100644 index 00000000..dafcf205 --- /dev/null +++ b/apps/open-swe/src/routes/github/pull-request-comment.ts @@ -0,0 +1,124 @@ +import { + PRWebhookHandlerBase, + PRWebhookContext, +} from "./pr-webhook-handler-base.js"; +import { constructLinkToPRComment } from "./utils.js"; +import { PullRequestReviewTriggerData } from "./types.js"; +import { createPromptFromPRCommentTrigger } from "./prompts.js"; +import { getRandomWebhookMessage } from "./webhook-messages.js"; + +class PRCommentWebhookHandler extends PRWebhookHandlerBase { + constructor() { + super("GitHubPRCommentHandler"); + } + + protected createPrompt(prData: PullRequestReviewTriggerData): string { + return createPromptFromPRCommentTrigger(prData); + } + + protected createCommentMessage(linkToTrigger: string): string { + return getRandomWebhookMessage("pr_comment", linkToTrigger); + } + + protected createTriggerLink( + context: PRWebhookContext, + triggerId: number | string, + ): string { + return constructLinkToPRComment({ + owner: context.owner, + repo: context.repo, + pullNumber: context.prNumber, + commentId: triggerId as number, + }); + } + + async handlePullRequestComment(payload: any): Promise { + // Only process comments on pull requests + if (!payload.issue.pull_request) { + return; + } + + const commentBody = payload.comment.body; + + if (!this.validateOpenSWEMention(commentBody, "Comment")) { + return; + } + + this.logger.info( + `@open-swe mentioned in PR #${payload.issue.number} comment`, + { + commentId: payload.comment.id, + author: payload.comment.user?.login, + }, + ); + + try { + const context = await this.setupPRWebhookContext(payload); + if (!context) { + return; + } + + // Get full PR details + const { data: pullRequest } = await context.octokit.request( + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + { + owner: context.owner, + repo: context.repo, + pull_number: context.prNumber, + }, + ); + + const { reviews, prComments, linkedIssues } = await this.fetchPRContext( + context, + pullRequest.body || "", + ); + + const prData = this.createPRTriggerData( + pullRequest, + context.prNumber, + { + id: payload.comment.id, + body: commentBody, + author: payload.comment.user?.login, + }, + prComments, + reviews, + linkedIssues, + { + owner: context.owner, + name: context.repo, + }, + ); + + const prompt = this.createPrompt(prData); + const runInput = this.createPRRunInput(prompt, context, pullRequest); + const configurable = this.createPRRunConfiguration(context); + + const { runId, threadId } = await this.createRun(context, { + runInput, + configurable, + }); + + const triggerLink = this.createTriggerLink(context, payload.comment.id); + const commentMessage = this.createCommentMessage(triggerLink); + + await this.createComment( + context, + { + issueNumber: context.prNumber, + message: commentMessage, + }, + runId, + threadId, + ); + } catch (error) { + this.handleError(error, "PR comment webhook"); + } + } +} + +const prCommentHandler = new PRCommentWebhookHandler(); + +export async function handlePullRequestComment(payload: any): Promise { + return prCommentHandler.handlePullRequestComment(payload); +} diff --git a/apps/open-swe/src/routes/github/pull-request-review-comment.ts b/apps/open-swe/src/routes/github/pull-request-review-comment.ts new file mode 100644 index 00000000..a9d04525 --- /dev/null +++ b/apps/open-swe/src/routes/github/pull-request-review-comment.ts @@ -0,0 +1,120 @@ +import { + PRWebhookHandlerBase, + PRWebhookContext, +} from "./pr-webhook-handler-base.js"; +import { constructLinkToPRReviewComment } from "./utils.js"; +import { PullRequestReviewTriggerData } from "./types.js"; +import { createPromptFromPRReviewCommentTrigger } from "./prompts.js"; +import { getRandomWebhookMessage } from "./webhook-messages.js"; + +class PRReviewCommentWebhookHandler extends PRWebhookHandlerBase { + constructor() { + super("GitHubPRReviewCommentHandler"); + } + + protected createPrompt(prData: PullRequestReviewTriggerData): string { + return createPromptFromPRReviewCommentTrigger(prData); + } + + protected createCommentMessage(linkToTrigger: string): string { + return getRandomWebhookMessage("pr_review_comment", linkToTrigger); + } + + protected createTriggerLink( + context: PRWebhookContext, + triggerId: number | string, + ): string { + return constructLinkToPRReviewComment({ + owner: context.owner, + repo: context.repo, + pullNumber: context.prNumber, + commentId: triggerId as number, + }); + } + + async handlePullRequestReviewComment(payload: any): Promise { + const commentBody = payload.comment.body; + + if (!this.validateOpenSWEMention(commentBody, "Review comment")) { + return; + } + + this.logger.info( + `@open-swe mentioned in PR #${payload.pull_request.number} review comment`, + { + commentId: payload.comment.id, + author: payload.comment.user?.login, + path: payload.comment.path, + line: payload.comment.line, + }, + ); + + try { + const context = await this.setupPRWebhookContext(payload); + if (!context) { + return; + } + + const { reviews, prComments, linkedIssues } = await this.fetchPRContext( + context, + payload.pull_request.body || "", + ); + + const prData = this.createPRTriggerData( + payload.pull_request, + context.prNumber, + { + id: payload.comment.id, + body: commentBody, + author: payload.comment.user?.login, + path: payload.comment.path, + line: payload.comment.line, + diff_hunk: payload.comment.diff_hunk, + }, + prComments, + reviews, + linkedIssues, + { + owner: context.owner, + name: context.repo, + }, + ); + + const prompt = this.createPrompt(prData); + const runInput = this.createPRRunInput( + prompt, + context, + payload.pull_request, + ); + const configurable = this.createPRRunConfiguration(context); + + const { runId, threadId } = await this.createRun(context, { + runInput, + configurable, + }); + + const triggerLink = this.createTriggerLink(context, payload.comment.id); + const commentMessage = this.createCommentMessage(triggerLink); + + await this.createComment( + context, + { + issueNumber: context.prNumber, + message: commentMessage, + }, + runId, + threadId, + ); + } catch (error) { + this.handleError(error, "PR review comment webhook"); + } + } +} + +const prReviewCommentHandler = new PRReviewCommentWebhookHandler(); + +export async function handlePullRequestReviewComment( + payload: any, +): Promise { + return prReviewCommentHandler.handlePullRequestReviewComment(payload); +} diff --git a/apps/open-swe/src/routes/github/pull-request-review.ts b/apps/open-swe/src/routes/github/pull-request-review.ts new file mode 100644 index 00000000..2372f6ad --- /dev/null +++ b/apps/open-swe/src/routes/github/pull-request-review.ts @@ -0,0 +1,114 @@ +import { + PRWebhookHandlerBase, + PRWebhookContext, +} from "./pr-webhook-handler-base.js"; +import { constructLinkToPRReview } from "./utils.js"; +import { PullRequestReviewTriggerData } from "./types.js"; +import { createPromptFromPRReviewTrigger } from "./prompts.js"; +import { getRandomWebhookMessage } from "./webhook-messages.js"; + +class PRReviewWebhookHandler extends PRWebhookHandlerBase { + constructor() { + super("GitHubPRReviewHandler"); + } + + protected createPrompt(prData: PullRequestReviewTriggerData): string { + return createPromptFromPRReviewTrigger(prData); + } + + protected createCommentMessage(linkToTrigger: string): string { + return getRandomWebhookMessage("pr_review", linkToTrigger); + } + + protected createTriggerLink( + context: PRWebhookContext, + triggerId: number | string, + ): string { + return constructLinkToPRReview({ + owner: context.owner, + repo: context.repo, + pullNumber: context.prNumber, + reviewId: triggerId as number, + }); + } + + async handlePullRequestReview(payload: any): Promise { + const reviewBody = payload.review.body; + + if (!this.validateOpenSWEMention(reviewBody, "Review")) { + return; + } + + this.logger.info( + `@open-swe mentioned in PR #${payload.pull_request.number} review`, + { + reviewId: payload.review.id, + author: payload.review.user?.login, + state: payload.review.state, + }, + ); + + try { + const context = await this.setupPRWebhookContext(payload); + if (!context) { + return; + } + + const { reviews, prComments, linkedIssues } = await this.fetchPRContext( + context, + payload.pull_request.body || "", + ); + + const prData = this.createPRTriggerData( + payload.pull_request, + context.prNumber, + { + id: payload.review.id, + body: reviewBody, + author: payload.review.user?.login, + }, + prComments, + reviews, + linkedIssues, + { + owner: context.owner, + name: context.repo, + }, + ); + + const prompt = this.createPrompt(prData); + const runInput = this.createPRRunInput( + prompt, + context, + payload.pull_request, + ); + const configurable = this.createPRRunConfiguration(context); + + const { runId, threadId } = await this.createRun(context, { + runInput, + configurable, + }); + + const triggerLink = this.createTriggerLink(context, payload.review.id); + const commentMessage = this.createCommentMessage(triggerLink); + + await this.createComment( + context, + { + issueNumber: context.prNumber, + message: commentMessage, + }, + runId, + threadId, + ); + } catch (error) { + this.handleError(error, "PR review webhook"); + } + } +} + +const prReviewHandler = new PRReviewWebhookHandler(); + +export async function handlePullRequestReview(payload: any): Promise { + return prReviewHandler.handlePullRequestReview(payload); +} diff --git a/apps/open-swe/src/routes/github/types.ts b/apps/open-swe/src/routes/github/types.ts new file mode 100644 index 00000000..ce418903 --- /dev/null +++ b/apps/open-swe/src/routes/github/types.ts @@ -0,0 +1,68 @@ +export interface SimplePullRequest { + number: number; + title: string; + body: string | undefined; + state: string; + author: string | undefined; + head: { + ref: string; + sha: string; + }; + base: { + ref: string; + sha: string; + }; +} + +export interface SimpleIssue { + id: number; + number: number; + title: string; + body: string | undefined; + state: string; + author: string | undefined; +} + +export interface SimpleTriggerComment { + id: number; + body: string; + author: string | undefined; + path?: string; + line?: number; + diff_hunk?: string; +} + +export interface SimplePullRequestComment { + id: number; + body: string | undefined; + author: string | undefined; +} + +export interface SimplePullRequestReviewComment { + id: number; + body: string | undefined; + author: string | undefined; + path: string; + line: number | undefined; + diff_hunk: string; +} + +export interface SimplePullRequestReview { + id: number; + body: string | undefined; + author: string | undefined; + state: string; + reviewComments: SimplePullRequestReviewComment[]; +} + +export interface PullRequestReviewTriggerData { + pullRequest: SimplePullRequest; + triggerComment: SimpleTriggerComment; + prComments: SimplePullRequestComment[]; + reviews: SimplePullRequestReview[]; + linkedIssues: SimpleIssue[]; + repository: { + owner: string; + name: string; + }; +} diff --git a/apps/open-swe/src/routes/github/unified-webhook.ts b/apps/open-swe/src/routes/github/unified-webhook.ts new file mode 100644 index 00000000..672ef242 --- /dev/null +++ b/apps/open-swe/src/routes/github/unified-webhook.ts @@ -0,0 +1,93 @@ +import { Context } from "hono"; +import { BlankEnv, BlankInput } from "hono/types"; +import { createLogger, LogLevel } from "../../utils/logger.js"; +import { Webhooks } from "@octokit/webhooks"; +import { handleIssueLabeled } from "./issue-labeled.js"; +import { handlePullRequestComment } from "./pull-request-comment.js"; +import { handlePullRequestReview } from "./pull-request-review.js"; +import { handlePullRequestReviewComment } from "./pull-request-review-comment.js"; + +const logger = createLogger(LogLevel.INFO, "GitHubUnifiedWebhook"); + +const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!; + +const webhooks = new Webhooks({ + secret: GITHUB_WEBHOOK_SECRET, +}); + +const getPayload = (body: string): Record | null => { + try { + const payload = JSON.parse(body); + return payload; + } catch { + return null; + } +}; + +const getHeaders = ( + c: Context, +): { + id: string; + name: string; + installationId: string; + targetType: string; +} | null => { + const headers = c.req.header(); + const webhookId = headers["x-github-delivery"] || ""; + const webhookEvent = headers["x-github-event"] || ""; + const installationId = headers["x-github-hook-installation-target-id"] || ""; + const targetType = headers["x-github-hook-installation-target-type"] || ""; + if (!webhookId || !webhookEvent || !installationId || !targetType) { + return null; + } + return { id: webhookId, name: webhookEvent, installationId, targetType }; +}; + +// Issue labeling events +webhooks.on("issues.labeled", async ({ payload }) => { + await handleIssueLabeled(payload); +}); + +// PR general comment events (discussion area) +webhooks.on("issue_comment.created", async ({ payload }) => { + await handlePullRequestComment(payload); +}); + +// PR review events (approve/request changes/comment) +webhooks.on("pull_request_review.submitted", async ({ payload }) => { + await handlePullRequestReview(payload); +}); + +// PR review comment events (inline code comments) +webhooks.on("pull_request_review_comment.created", async ({ payload }) => { + await handlePullRequestReviewComment(payload); +}); + +export async function unifiedWebhookHandler( + c: Context, +) { + const payload = getPayload(await c.req.text()); + if (!payload) { + logger.error("Missing payload"); + return c.json({ error: "Missing payload" }, { status: 400 }); + } + + const eventHeaders = getHeaders(c); + if (!eventHeaders) { + logger.error("Missing webhook headers"); + return c.json({ error: "Missing webhook headers" }, { status: 400 }); + } + + try { + await webhooks.receive({ + id: eventHeaders.id, + name: eventHeaders.name as any, + payload, + }); + + return c.json({ received: true }); + } catch (error) { + logger.error("Webhook error:", error); + return c.json({ error: "Webhook processing failed" }, { status: 400 }); + } +} diff --git a/apps/open-swe/src/routes/github/utils.ts b/apps/open-swe/src/routes/github/utils.ts new file mode 100644 index 00000000..f447e134 --- /dev/null +++ b/apps/open-swe/src/routes/github/utils.ts @@ -0,0 +1,256 @@ +import { v4 as uuidv4 } from "uuid"; +import { Octokit } from "@octokit/core"; +import { GitHubPullRequestGet } from "../../utils/github/types.js"; +import { + SimpleIssue, + SimplePullRequest, + SimplePullRequestComment, + SimplePullRequestReview, +} from "./types.js"; +import { createLangGraphClient } from "../../utils/langgraph-client.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_INSTALLATION_NAME, + GITHUB_USER_ID_HEADER, + GITHUB_USER_LOGIN_HEADER, + GITHUB_INSTALLATION_ID, + MANAGER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, +} from "@open-swe/shared/constants"; +import { encryptSecret } from "@open-swe/shared/crypto"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; +import { StreamMode } from "@langchain/langgraph-sdk"; +import { extractContentWithoutDetailsFromIssueBody } from "../../utils/github/issue-messages.js"; + +export function createDevMetadataComment(runId: string, threadId: string) { + return `
+ Dev Metadata + ${JSON.stringify( + { + runId, + threadId, + }, + null, + 2, + )} +
`; +} + +export function mentionsOpenSWE(commentBody: string): boolean { + return /@open-swe\b/.test(commentBody); +} + +export function extractLinkedIssues(prBody: string): number[] { + // Look for common patterns like "fixes #123", "closes #456", "resolves #789" + const patterns = [ + /(?:fixes?|closes?|resolves?)\s+#(\d+)/gi, + /(?:fix|close|resolve)\s+#(\d+)/gi, + ]; + + const issueNumbers: number[] = []; + patterns.forEach((pattern) => { + let match; + while ((match = pattern.exec(prBody)) !== null) { + issueNumbers.push(parseInt(match[1], 10)); + } + }); + + return [...new Set(issueNumbers)]; // Remove duplicates +} + +/** + * Fetches PR discussion context split into: + * - prComments: top-level PR comments (issue comments on the PR) + * - reviews: PR reviews including their own reviewComments + */ +export async function getPrContext( + octokit: Octokit, + inputs: { + owner: string; + repo: string; + prNumber: number; + linkedIssueNumbers: number[]; + }, +): Promise<{ + prComments: SimplePullRequestComment[]; + reviews: SimplePullRequestReview[]; + linkedIssues: SimpleIssue[]; +}> { + const { owner, repo, prNumber, linkedIssueNumbers } = inputs; + + const [issueCommentsRes, reviewCommentsRes, reviewsRes] = await Promise.all([ + octokit.request( + "GET /repos/{owner}/{repo}/issues/{issue_number}/comments", + { + owner, + repo, + issue_number: prNumber, + }, + ), + octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/comments", { + owner, + repo, + pull_number: prNumber, + }), + octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews", { + owner, + repo, + pull_number: prNumber, + }), + ]); + + const linkedIssuesRes = await Promise.all( + linkedIssueNumbers.map((issueNumber) => + octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}", { + owner, + repo, + issue_number: issueNumber, + }), + ), + ); + + const issueComments = issueCommentsRes.data; + const allReviewComments = reviewCommentsRes.data; + const reviews = reviewsRes.data; + const linkedIssues = linkedIssuesRes.map((res) => res.data); + + // Group review comments by their parent review id + const commentsByReviewId = new Map(); + for (const c of allReviewComments) { + const rid = c.pull_request_review_id as number | undefined; + if (!rid) continue; // Only include comments that belong to a specific review + const arr = commentsByReviewId.get(rid) ?? []; + arr.push(c); + commentsByReviewId.set(rid, arr); + } + + return { + prComments: issueComments.map((comment) => ({ + id: comment.id, + body: comment.body, + author: comment.user?.login, + })), + reviews: reviews.map((review) => ({ + id: review.id, + body: review.body ?? undefined, + author: review.user?.login, + state: review.state, + reviewComments: (commentsByReviewId.get(review.id) ?? []).map( + (comment) => ({ + id: comment.id, + body: comment.body, + author: comment.user?.login, + path: comment.path, + line: comment.line, + diff_hunk: comment.diff_hunk, + }), + ), + })), + linkedIssues: linkedIssues.map((issue) => ({ + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body + ? extractContentWithoutDetailsFromIssueBody(issue.body) + : undefined, + state: issue.state, + author: issue.user?.login, + })), + }; +} + +export function convertPRPayloadToPullRequestObj( + payloadPullRequest: GitHubPullRequestGet, + prNumber: number, +): SimplePullRequest { + return { + number: prNumber, + title: payloadPullRequest.title, + body: payloadPullRequest.body ?? "", + state: payloadPullRequest.state, + author: payloadPullRequest.user?.login, + head: { + ref: payloadPullRequest.head.ref, + sha: payloadPullRequest.head.sha, + }, + base: { + ref: payloadPullRequest.base.ref, + sha: payloadPullRequest.base.sha, + }, + }; +} + +export async function createRunFromWebhook(inputs: { + installationId: number; + installationToken: string; + userId: number; + userLogin: string; + installationName: string; + runInput: ManagerGraphUpdate; + configurable?: Partial; +}): Promise<{ + runId: string; + threadId: string; +}> { + if (!process.env.SECRETS_ENCRYPTION_KEY) { + throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required"); + } + const langGraphClient = createLangGraphClient({ + defaultHeaders: { + [GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret( + inputs.installationToken, + process.env.SECRETS_ENCRYPTION_KEY, + ), + [GITHUB_INSTALLATION_NAME]: inputs.installationName, + [GITHUB_USER_ID_HEADER]: inputs.userId.toString(), + [GITHUB_USER_LOGIN_HEADER]: inputs.userLogin, + [GITHUB_INSTALLATION_ID]: inputs.installationId.toString(), + }, + }); + + const threadId = uuidv4(); + + const run = await langGraphClient.runs.create(threadId, MANAGER_GRAPH_ID, { + input: inputs.runInput, + config: { + recursion_limit: 400, + configurable: inputs.configurable, + }, + ifNotExists: "create", + streamResumable: true, + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }); + + return { + runId: run.run_id, + threadId, + }; +} + +export function constructLinkToPRComment(inputs: { + owner: string; + repo: string; + pullNumber: number; + commentId: number; +}) { + return `https://github.com/${inputs.owner}/${inputs.repo}/pull/${inputs.pullNumber}#issuecomment-${inputs.commentId}`; +} + +export function constructLinkToPRReviewComment(inputs: { + owner: string; + repo: string; + pullNumber: number; + commentId: number; +}) { + return `https://github.com/${inputs.owner}/${inputs.repo}/pull/${inputs.pullNumber}#discussion_r${inputs.commentId}`; +} + +export function constructLinkToPRReview(inputs: { + owner: string; + repo: string; + pullNumber: number; + reviewId: number; +}) { + return `https://github.com/${inputs.owner}/${inputs.repo}/pull/${inputs.pullNumber}#pullrequestreview-${inputs.reviewId}`; +} diff --git a/apps/open-swe/src/routes/github/webhook-handler-base.ts b/apps/open-swe/src/routes/github/webhook-handler-base.ts new file mode 100644 index 00000000..80944e93 --- /dev/null +++ b/apps/open-swe/src/routes/github/webhook-handler-base.ts @@ -0,0 +1,155 @@ +import { v4 as uuidv4 } from "uuid"; +import { createLogger, LogLevel } from "../../utils/logger.js"; +import { GitHubApp } from "../../utils/github-app.js"; +import { isAllowedUser } from "@open-swe/shared/github/allowed-users"; +import { HumanMessage } from "@langchain/core/messages"; +import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; +import { RequestSource } from "../../constants.js"; +import { getOpenSweAppUrl } from "../../utils/url-helpers.js"; +import { createRunFromWebhook, createDevMetadataComment } from "./utils.js"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { Octokit } from "@octokit/core"; + +export interface WebhookHandlerContext { + installationId: number; + octokit: Octokit; + token: string; + owner: string; + repo: string; + userLogin: string; + userId: number; +} + +export interface RunArgs { + runInput: ManagerGraphUpdate; + configurable?: Partial; +} + +export interface CommentConfiguration { + issueNumber: number; + message: string; +} + +export class WebhookHandlerBase { + protected logger: ReturnType; + protected githubApp: GitHubApp; + + constructor(loggerName: string) { + this.logger = createLogger(LogLevel.INFO, loggerName); + this.githubApp = new GitHubApp(); + } + + /** + * Validates and sets up the webhook context with installation and user validation + */ + protected async setupWebhookContext( + payload: any, + ): Promise { + const installationId = payload.installation?.id; + if (!installationId) { + this.logger.error("No installation ID found in webhook payload"); + return null; + } + + if (!isAllowedUser(payload.sender.login)) { + this.logger.error("User is not a member of allowed orgs", { + username: payload.sender.login, + }); + return null; + } + + const [octokit, { token }] = await Promise.all([ + this.githubApp.getInstallationOctokit(installationId), + this.githubApp.getInstallationAccessToken(installationId), + ]); + + return { + installationId, + octokit, + token, + owner: payload.repository.owner.login, + repo: payload.repository.name, + userLogin: payload.sender.login, + userId: payload.sender.id, + }; + } + + /** + * Creates a run from webhook with the provided configuration + */ + protected async createRun( + context: WebhookHandlerContext, + args: RunArgs, + ): Promise<{ runId: string; threadId: string }> { + const { runId, threadId } = await createRunFromWebhook({ + installationId: context.installationId, + installationToken: context.token, + userId: context.userId, + userLogin: context.userLogin, + installationName: context.owner, + runInput: args.runInput, + configurable: args.configurable || {}, + }); + + this.logger.info("Created new run from GitHub webhook.", { + threadId, + runId, + }); + + return { runId, threadId }; + } + + /** + * Creates a comment on the issue/PR with the provided configuration + */ + protected async createComment( + context: WebhookHandlerContext, + config: CommentConfiguration, + runId: string, + threadId: string, + ): Promise { + this.logger.info("Creating comment..."); + + const appUrl = getOpenSweAppUrl(threadId); + const appUrlCommentText = appUrl + ? `View run in Open SWE [here](${appUrl}) (this URL will only work for @${context.userLogin})` + : ""; + + const fullMessage = `${config.message}\n\n${appUrlCommentText}\n\n${createDevMetadataComment(runId, threadId)}`; + + await context.octokit.request( + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + { + owner: context.owner, + repo: context.repo, + issue_number: config.issueNumber, + body: fullMessage, + }, + ); + } + + /** + * Creates a HumanMessage with the provided content and request source + */ + protected createHumanMessage( + content: string, + requestSource: RequestSource, + additionalKwargs: Record = {}, + ): HumanMessage { + return new HumanMessage({ + id: uuidv4(), + content, + additional_kwargs: { + requestSource, + ...additionalKwargs, + }, + }); + } + + /** + * Handles errors consistently across all webhook handlers + */ + protected handleError(error: any, context: string): void { + this.logger.error(`Error processing ${context}:`, error); + } +} diff --git a/apps/open-swe/src/routes/github/webhook-messages.ts b/apps/open-swe/src/routes/github/webhook-messages.ts new file mode 100644 index 00000000..a37ddc08 --- /dev/null +++ b/apps/open-swe/src/routes/github/webhook-messages.ts @@ -0,0 +1,76 @@ +/** + * Random message selector for GitHub webhook responses + */ + +export type WebhookMessageType = + | "pr_review" + | "pr_review_comment" + | "pr_comment"; + +const PR_REVIEW_MESSAGES = [ + "🤖 I'll start working on [this PR review]({link}). Time to channel my inner code whisperer!", + "🤖 Got your [PR review]({link})! Let me put on my debugging cape and get to work.", + "🤖 [This review]({link}) looks interesting... Time to work some magic! ✨", + "🤖 Challenge accepted! Working on [this PR review]({link}) now.", + "🤖 I see you've summoned me for [this review]({link}). Let's make it happen! 🚀", + "🤖 Time to dive into [this PR review]({link}). Hold my coffee, I'm going in!", + "🤖 [This review]({link}) won't know what hit it. Starting work now!", + "🤖 Beep boop! Processing [this PR review]({link}) with maximum efficiency.", + "🤖 Your wish is my command! Tackling [this review]({link}) right away.", + "🤖 Plot twist: I actually enjoy [reviews like this]({link}). Let's do this! 🎯", +]; + +const PR_REVIEW_COMMENT_MESSAGES = [ + "🤖 Interesting... I've received your [PR review comment]({link}). Time to work my magic!", + "🤖 Spotted your [review comment]({link})! Let me channel my inner Sherlock Holmes. 🔍", + "🤖 [This comment]({link}) has my full attention. Prepare for some serious code wizardry!", + "🤖 Your [review comment]({link}) is now on my radar. Initiating fix sequence... 🎯", + "🤖 I see what you did there with [this comment]({link}). Challenge accepted!", + "🤖 [This review comment]({link}) looks spicy! 🌶️ Let me handle it with care.", + "🤖 Roger that! Working on [your comment]({link}) with the precision of a Swiss watch.", + "🤖 [This comment]({link}) activated my developer mode. Time to get things done!", + "🤖 Your [review comment]({link}) is like a puzzle piece - let me find where it fits! 🧩", + "🤖 Beep beep! [This comment]({link}) is now in my priority queue. Processing...", +]; + +const PR_COMMENT_MESSAGES = [ + "🤖 Got it! I'll start working on [this comment]({link}). Let the coding commence!", + "🤖 [Your comment]({link}) has been received loud and clear! Time to make it happen.", + "🤖 I see you've tagged me in [this comment]({link}). Consider it done! ✅", + "🤖 [This comment]({link}) is now my main quest. Loading... please wait! 🎮", + "🤖 Your [comment]({link}) just made my day! Let me work on this right away.", + "🤖 Aha! [This comment]({link}) is exactly what I needed. Time to shine! ⭐", + "🤖 [Your comment]({link}) activated my productivity mode. Buckle up!", + "🤖 I'm on it! [This comment]({link}) is getting the VIP treatment. 👑", + "🤖 [This comment]({link}) speaks to my soul. Let me craft the perfect solution!", + "🤖 Bingo! [Your comment]({link}) is now in my capable digital hands. Watch this space! 🚀", +]; + +/** + * Selects a random message for the specified webhook type + */ +export function getRandomWebhookMessage( + type: WebhookMessageType, + linkToTrigger: string, +): string { + let messages: string[]; + + switch (type) { + case "pr_review": + messages = PR_REVIEW_MESSAGES; + break; + case "pr_review_comment": + messages = PR_REVIEW_COMMENT_MESSAGES; + break; + case "pr_comment": + messages = PR_COMMENT_MESSAGES; + break; + default: + throw new Error(`Unknown webhook message type: ${type}`); + } + + const randomIndex = Math.floor(Math.random() * messages.length); + const selectedMessage = messages[randomIndex]; + + return selectedMessage.replace("{link}", linkToTrigger); +} diff --git a/apps/open-swe/src/tools/reply-to-review-comment.ts b/apps/open-swe/src/tools/reply-to-review-comment.ts new file mode 100644 index 00000000..392ea010 --- /dev/null +++ b/apps/open-swe/src/tools/reply-to-review-comment.ts @@ -0,0 +1,134 @@ +import { tool } from "@langchain/core/tools"; +import { + createReplyToCommentToolFields, + createReplyToReviewCommentToolFields, + createReplyToReviewToolFields, +} from "@open-swe/shared/open-swe/tools"; +import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; +import { GraphConfig, GraphState } from "@open-swe/shared/open-swe/types"; +import { + quoteReplyToPullRequestComment, + quoteReplyToReview, + replyToReviewComment, +} from "../utils/github/api.js"; +import { getRecentUserRequest } from "../utils/user-request.js"; +import { RequestSource } from "../constants.js"; +import { GITHUB_USER_LOGIN_HEADER } from "@open-swe/shared/constants"; + +export function shouldIncludeReviewCommentTool( + state: GraphState, + config: GraphConfig, +): boolean { + const userMessage = getRecentUserRequest(state.messages, { + returnFullMessage: true, + config, + }); + const shouldIncludeReviewCommentTool = + userMessage.additional_kwargs?.requestSource === + RequestSource.GITHUB_PULL_REQUEST_WEBHOOK || + !!config.configurable?.reviewPullNumber; + return shouldIncludeReviewCommentTool; +} + +export function createReplyToReviewCommentTool( + state: Pick, + config: GraphConfig, +) { + const replyToReviewCommentTool = tool( + async (input): Promise<{ result: string; status: "success" | "error" }> => { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const { reviewPullNumber } = config.configurable ?? {}; + + if (!reviewPullNumber) { + throw new Error("No pull request number found"); + } + + await replyToReviewComment({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + commentId: input.id, + body: input.comment, + pullNumber: reviewPullNumber, + githubInstallationToken, + }); + + return { + result: "Successfully replied to review comment.", + status: "success", + }; + }, + createReplyToReviewCommentToolFields(), + ); + + return replyToReviewCommentTool; +} + +export function createReplyToCommentTool( + state: Pick, + config: GraphConfig, +) { + const replyToReviewCommentTool = tool( + async (input): Promise<{ result: string; status: "success" | "error" }> => { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const reviewPullNumber = config.configurable?.reviewPullNumber; + const userLogin = config.configurable?.[GITHUB_USER_LOGIN_HEADER]; + + if (!reviewPullNumber || !userLogin) { + throw new Error("No pull request number or user login found"); + } + + await quoteReplyToPullRequestComment({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + commentId: input.id, + body: input.comment, + pullNumber: reviewPullNumber, + originalCommentUserLogin: userLogin, + githubInstallationToken, + }); + + return { + result: "Successfully replied to review comment.", + status: "success", + }; + }, + createReplyToCommentToolFields(), + ); + + return replyToReviewCommentTool; +} + +export function createReplyToReviewTool( + state: Pick, + config: GraphConfig, +) { + const replyToReviewTool = tool( + async (input): Promise<{ result: string; status: "success" | "error" }> => { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const reviewPullNumber = config.configurable?.reviewPullNumber; + const userLogin = config.configurable?.[GITHUB_USER_LOGIN_HEADER]; + + if (!reviewPullNumber || !userLogin) { + throw new Error("No pull request number or user login found"); + } + + await quoteReplyToReview({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + reviewCommentId: input.id, + body: input.comment, + pullNumber: reviewPullNumber, + originalCommentUserLogin: userLogin, + githubInstallationToken, + }); + + return { + result: "Successfully replied to review.", + status: "success", + }; + }, + createReplyToReviewToolFields(), + ); + + return replyToReviewTool; +} diff --git a/apps/open-swe/src/utils/config.ts b/apps/open-swe/src/utils/config.ts index 835cea88..7da68d22 100644 --- a/apps/open-swe/src/utils/config.ts +++ b/apps/open-swe/src/utils/config.ts @@ -16,7 +16,7 @@ export function getCustomConfigurableFields( if (key in config.configurable) { if ( metadataValue.x_open_swe_ui_config.type !== "hidden" || - key === "apiKeys" + ["apiKeys", "reviewPullNumber"].includes(key) ) { result[key as keyof GraphConfig["configurable"]] = config.configurable[key as keyof GraphConfig["configurable"]]; diff --git a/apps/open-swe/src/utils/github/api.ts b/apps/open-swe/src/utils/github/api.ts index 82f91e0a..baa5e31e 100644 --- a/apps/open-swe/src/utils/github/api.ts +++ b/apps/open-swe/src/utils/github/api.ts @@ -7,6 +7,7 @@ import { GitHubPullRequest, GitHubPullRequestList, GitHubPullRequestUpdate, + GitHubReviewComment, } from "./types.js"; import { getOpenSWELabel } from "./label.js"; import { getInstallationToken } from "@open-swe/shared/github/auth"; @@ -626,3 +627,142 @@ export async function getBranch({ 1, ); } + +export async function replyToReviewComment({ + owner, + repo, + commentId, + body, + pullNumber, + githubInstallationToken, +}: { + owner: string; + repo: string; + commentId: number; + body: string; + pullNumber: number; + githubInstallationToken: string; +}): Promise { + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); + + const { data: comment } = await octokit.pulls.createReplyForReviewComment( + { + owner, + repo, + comment_id: commentId, + pull_number: pullNumber, + body, + }, + ); + + return comment; + }, + githubInstallationToken, + "Failed to reply to review comment", + undefined, + 1, + ); +} + +export async function quoteReplyToPullRequestComment({ + owner, + repo, + commentId, + body, + pullNumber, + originalCommentUserLogin, + githubInstallationToken, +}: { + owner: string; + repo: string; + commentId: number; + body: string; + pullNumber: number; + originalCommentUserLogin: string; + githubInstallationToken: string; +}): Promise { + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); + + const originalComment = await octokit.issues.getComment({ + owner, + repo, + comment_id: commentId, + }); + + const quoteReply = `${originalComment.data.body ? `> ${originalComment.data.body}` : ""} + +@${originalCommentUserLogin} ${body}`; + + const { data: comment } = await octokit.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body: quoteReply, + }); + + return comment; + }, + githubInstallationToken, + "Failed to quote reply to pull request comment", + undefined, + 1, + ); +} + +export async function quoteReplyToReview({ + owner, + repo, + reviewCommentId, + body, + pullNumber, + originalCommentUserLogin, + githubInstallationToken, +}: { + owner: string; + repo: string; + reviewCommentId: number; + body: string; + pullNumber: number; + originalCommentUserLogin: string; + githubInstallationToken: string; +}): Promise { + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); + + const originalComment = await octokit.pulls.getReview({ + owner, + repo, + pull_number: pullNumber, + review_id: reviewCommentId, + }); + + const quoteReply = `${originalComment.data.body ? `> ${originalComment.data.body}` : ""} + +@${originalCommentUserLogin} ${body}`; + + const { data: comment } = await octokit.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body: quoteReply, + }); + + return comment; + }, + githubInstallationToken, + "Failed to quote reply to pull request review", + undefined, + 1, + ); +} diff --git a/apps/open-swe/src/utils/github/git.ts b/apps/open-swe/src/utils/github/git.ts index 7ba97160..84c4cea5 100644 --- a/apps/open-swe/src/utils/github/git.ts +++ b/apps/open-swe/src/utils/github/git.ts @@ -342,12 +342,14 @@ export async function checkoutBranchAndCommit( logger.info("First commit detected, creating a draft pull request."); const hasIssue = shouldCreateIssue(config); + const reviewPullNumber = config.configurable?.reviewPullNumber; + const pullRequest = await createPullRequest({ owner: targetRepository.owner, repo: targetRepository.repo, headBranch: branchName, title: `[WIP]: ${activeTask?.title ?? "Open SWE task"}`, - body: `**WORK IN PROGRESS OPEN SWE PR**${hasIssue ? `\n\nFixes: #${options.githubIssueId}` : ""}`, + body: `**WORK IN PROGRESS OPEN SWE PR**${hasIssue ? `\n\nFixes: #${options.githubIssueId}` : ""}${reviewPullNumber ? `\n\nTriggered from pull request: #${reviewPullNumber}` : ""}`, githubInstallationToken: options.githubInstallationToken, draft: true, baseBranch: targetRepository.branch, diff --git a/apps/open-swe/src/utils/github/issue-messages.ts b/apps/open-swe/src/utils/github/issue-messages.ts index 540b5aea..0fa83b4a 100644 --- a/apps/open-swe/src/utils/github/issue-messages.ts +++ b/apps/open-swe/src/utils/github/issue-messages.ts @@ -8,7 +8,7 @@ import { GitHubIssue, GitHubIssueComment } from "./types.js"; import { getIssue, getIssueComments } from "./api.js"; import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types"; import { getGitHubTokensFromConfig } from "../github-tokens.js"; -import { DETAILS_OPEN_TAG } from "./issue-task.js"; +import { DETAILS_CLOSE_TAG, DETAILS_OPEN_TAG } from "./issue-task.js"; import { isLocalMode } from "@open-swe/shared/open-swe/local-mode"; export function getUntrackedComments( @@ -161,7 +161,10 @@ export function extractContentWithoutDetailsFromIssueBody( } const bodyWithoutDetails = extractContentFromIssueBody( - body.split(DETAILS_OPEN_TAG)[0], + body.substring( + body.indexOf(DETAILS_OPEN_TAG) + DETAILS_OPEN_TAG.length, + body.indexOf(DETAILS_CLOSE_TAG), + ), ); return bodyWithoutDetails; } @@ -170,7 +173,10 @@ export function getMessageContentFromIssue( issue: GitHubIssue | GitHubIssueComment, ): string { if ("title" in issue) { - return `[original issue]\n**${issue.title}**\n${extractContentFromIssueBody(issue.body ?? "")}`; + const formattedBody = extractContentWithoutDetailsFromIssueBody( + issue.body ?? "", + ); + return `[original issue]\n**${issue.title}**\n${formattedBody}`; } return `[issue comment]\n${issue.body}`; } diff --git a/apps/open-swe/src/utils/github/types.ts b/apps/open-swe/src/utils/github/types.ts index e093cf2c..da9e3443 100644 --- a/apps/open-swe/src/utils/github/types.ts +++ b/apps/open-swe/src/utils/github/types.ts @@ -17,3 +17,9 @@ export type GitHubPullRequestList = export type GitHubBranch = RestEndpointMethodTypes["repos"]["getBranch"]["response"]["data"]; + +export type GitHubPullRequestGet = + RestEndpointMethodTypes["pulls"]["get"]["response"]["data"]; + +export type GitHubReviewComment = + RestEndpointMethodTypes["pulls"]["createReviewComment"]["response"]["data"]; diff --git a/packages/shared/src/open-swe/tools.ts b/packages/shared/src/open-swe/tools.ts index 6def12c3..5f4e1437 100644 --- a/packages/shared/src/open-swe/tools.ts +++ b/packages/shared/src/open-swe/tools.ts @@ -662,3 +662,73 @@ export function createWriteDefaultTsConfigToolFields( schema: writeDefaultTsConfigToolSchema, }; } + +export function createReplyToReviewCommentToolFields() { + const commentOnReviewCommentSchema = z.object({ + id: z + .number() + .describe( + "The ID of the review comment to reply to. This must be a valid ID from a review comment.", + ), + comment: z + .string() + .describe( + "The reply to leave on the review comment. This should be a concise reply.", + ), + }); + + return { + name: "reply_to_review_comment", + description: + "Use this tool to reply to a review comment. You should call this tool to reply to user comments if they don't require modifying code but instead are requesting information." + + "\nAdditionally, this should be called to indicate to the user that a comment has been resolved, or if you're unable to resolve the comment call this tool to let the user know you're unable to resolve the comment." + + "\nONLY USE THIS FOR REVIEW COMMENTS. FOR NORMAL COMMENTS USE THE 'reply_to_comment' TOOL.", + schema: commentOnReviewCommentSchema, + }; +} + +export function createReplyToCommentToolFields() { + const commentOnReviewCommentSchema = z.object({ + id: z + .number() + .describe( + "The ID of the comment to reply to. This must be a valid ID from a comment.", + ), + comment: z + .string() + .describe( + "The reply to leave on the comment. This should be a concise reply.", + ), + }); + + return { + name: "reply_to_comment", + description: + "Use this tool to reply to a comment. This tool should always be called if you have just resolved a comment (NOT A REVIEW COMMENT) the user tagged you in. Use this tool to concisely let the user know you've resolved their comment, or to answer their comment." + + "\nONLY USE THIS FOR NORMAL COMMENTS. FOR REVIEW COMMENTS USE THE 'reply_to_review_comment' TOOL.", + schema: commentOnReviewCommentSchema, + }; +} + +export function createReplyToReviewToolFields() { + const commentOnReviewSchema = z.object({ + id: z + .number() + .describe( + "The ID of the review to reply to. This must be a valid ID from a review. NOT A REVIEW COMMENT", + ), + comment: z + .string() + .describe( + "The reply to leave on the review. This should be a concise reply.", + ), + }); + + return { + name: "reply_to_review", + description: + "Use this tool to reply to a review. This tool should always be called if you have just resolved a review (NOT A REVIEW COMMENT) the user tagged you in. Use this tool to concisely let the user know you've resolved their review, or to answer their review." + + "\nONLY USE THIS FOR NORMAL REVIEWS. FOR REVIEW COMMENTS USE THE 'reply_to_review_comment' TOOL.", + schema: commentOnReviewSchema, + }; +} diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index c4ed2eef..12446715 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -457,6 +457,11 @@ export const GraphConfigurationMetadata: { "Whether or not to create GitHub issues for all requests. Can be overridden on a per-request basis via the 'eye' icon in the chat input area.", }, }, + reviewPullNumber: { + x_open_swe_ui_config: { + type: "hidden", + }, + }, apiKeys: { x_open_swe_ui_config: { type: "hidden", @@ -629,6 +634,13 @@ export const GraphConfiguration = z.object({ shouldCreateIssue: withLangGraph(z.boolean().optional(), { metadata: GraphConfigurationMetadata.shouldCreateIssue, }), + /** + * The pull request number that this run is associated with. + * @default undefined + */ + reviewPullNumber: withLangGraph(z.number().optional(), { + metadata: GraphConfigurationMetadata.reviewPullNumber, + }), /** * User defined API keys to use */