From 38767397e9097727df02914bf7cb0f85901f61d1 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Tue, 22 Jul 2025 15:09:28 -0700 Subject: [PATCH] feat: Send followup comment to issues when plan is proposed/accepted (#444) * feat: Send followup comment to issues when plan is proposed/accepted * cr * cr --- .../src/graphs/planner/nodes/proposed-plan.ts | 120 ++++++++++++++++++ apps/open-swe/src/utils/github/api.ts | 34 +++++ 2 files changed, 154 insertions(+) diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 1eb2872b..17ecea7f 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -37,9 +37,97 @@ import { } from "@open-swe/shared/open-swe/custom-node-events"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; import { getCustomConfigurableFields } from "../../../utils/config.js"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; +import { + createIssueComment, + getIssueComments, + updateIssueComment, +} from "../../../utils/github/api.js"; const logger = createLogger(LogLevel.INFO, "ProposedPlan"); +const PLAN_MESSAGE_OPEN_TAG = ""; +const PLAN_MESSAGE_CLOSE_TAG = ""; + +function formatBodyWithPlanMessage(body: string, message: string): string { + if ( + body.includes(PLAN_MESSAGE_OPEN_TAG) && + body.includes(PLAN_MESSAGE_CLOSE_TAG) + ) { + const bodyBeforeTag = body.split(PLAN_MESSAGE_OPEN_TAG)[0]; + const bodyAfterTag = body.split(PLAN_MESSAGE_CLOSE_TAG)[1]; + const newInnerContents = `\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}\n`; + return `${bodyBeforeTag}${newInnerContents}${bodyAfterTag}`; + } + + return `${body}\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}`; +} + +function cleanTaskItems(taskItem: string): string { + return "```\n" + taskItem.replace("```", "\\```") + "\n```"; +} + +/** + * Posts a comment to a GitHub issue using the installation token + */ +async function postGitHubIssueComment(input: { + githubIssueId: number; + targetRepository: { owner: string; repo: string }; + commentBody: string; + config: GraphConfig; +}): Promise { + const { githubIssueId, targetRepository, commentBody, config } = input; + const githubAppName = process.env.GITHUB_APP_NAME; + if (!githubAppName) { + throw new Error("GITHUB_APP_NAME not set"); + } + + try { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const existingComments = await getIssueComments({ + owner: targetRepository.owner, + repo: targetRepository.repo, + issueNumber: githubIssueId, + githubInstallationToken, + }); + + const existingOpenSWEComment = existingComments?.findLast((c) => + c.user?.login?.startsWith(githubAppName), + ); + + if (!existingOpenSWEComment) { + await createIssueComment({ + owner: targetRepository.owner, + repo: targetRepository.repo, + issueNumber: githubIssueId, + body: commentBody, + githubToken: githubInstallationToken, + }); + + logger.info(`Posted comment to GitHub issue #${githubIssueId}`); + return; + } + + // Update the comment + const newCommentBody = formatBodyWithPlanMessage( + existingOpenSWEComment.body ?? "", + commentBody, + ); + await updateIssueComment({ + owner: targetRepository.owner, + repo: targetRepository.repo, + commentId: existingOpenSWEComment.id, + body: newCommentBody, + githubInstallationToken, + }); + + logger.info(`Updated comment to GitHub issue #${githubIssueId}`); + } catch (error) { + logger.error("Failed to post GitHub comment:", error); + // Don't throw - we don't want to fail the entire process if comment posting fails + } +} + function createAcceptedPlanMessage(input: { planTitle: string; planItems: PlanItem[]; @@ -161,6 +249,15 @@ export async function interruptProposedPlan( if (state.autoAcceptPlan) { logger.info("Auto accepting plan."); + + // Post comment to GitHub issue about auto-accepting the plan + await postGitHubIssueComment({ + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + commentBody: `### 🤖 Plan Generated\n\nI've generated a plan for this issue and will proceed to implement it since auto-accept is enabled.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${proposedPlan.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step)}`).join("\n")}\n\nProceeding to implementation...`, + config, + }); + planItems = proposedPlan.map((p, index) => ({ index, plan: p, @@ -199,6 +296,14 @@ export async function interruptProposedPlan( proposedPlan, ); + // Post comment to GitHub issue about plan being ready for approval + await postGitHubIssueComment({ + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + commentBody: `### 🟠 Plan Ready for Approval 🟠\n\nI've generated a plan for this issue and it's ready for your review.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${proposedPlan.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step)}`).join("\n")}\n\nPlease review the plan and let me know if you'd like me to proceed, make changes, or if you have any feedback.`, + config, + }); + const interruptResponse = interrupt< HumanInterrupt, HumanResponse[] | HumanResponse @@ -251,6 +356,14 @@ export async function interruptProposedPlan( planItems, { existingTaskPlan: state.taskPlan }, ); + + // Update the comment to notify the user that the plan was accepted + await postGitHubIssueComment({ + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + commentBody: `### ✅ Plan Accepted ✅\n\nThe proposed plan was accepted.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${planItems.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step.plan)}`).join("\n")}\n\nProceeding to implementation...`, + config, + }); } else if (humanResponse.type === "edit") { const editedPlan = (humanResponse.args as ActionRequest).args.plan .split(PLAN_INTERRUPT_DELIMITER) @@ -268,6 +381,13 @@ export async function interruptProposedPlan( planItems, { existingTaskPlan: state.taskPlan }, ); + + await postGitHubIssueComment({ + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + commentBody: `### ✅ Plan Edited & Submitted ✅\n\nThe proposed plan was edited and submitted.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${planItems.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step.plan)}`).join("\n")}\n\nProceeding to implementation...`, + config, + }); } else { throw new Error("Unknown interrupt type." + humanResponse.type); } diff --git a/apps/open-swe/src/utils/github/api.ts b/apps/open-swe/src/utils/github/api.ts index 8a730a7c..48680002 100644 --- a/apps/open-swe/src/utils/github/api.ts +++ b/apps/open-swe/src/utils/github/api.ts @@ -341,3 +341,37 @@ export async function createIssueComment({ return null; } } + +export async function updateIssueComment({ + owner, + repo, + commentId, + body, + githubInstallationToken, +}: { + owner: string; + repo: string; + commentId: number; + body: string; + githubInstallationToken: string; +}): Promise { + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + + try { + const { data: comment } = await octokit.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body, + }); + + return comment; + } catch (error) { + logger.error(`Failed to update issue comment`, { + error, + }); + return null; + } +}