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
This commit is contained in:
Brace Sproul 2025-07-22 15:09:28 -07:00 • committed by GitHub
parent 81dbcbaa4d
commit 38767397e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 154 additions and 0 deletions

View file

@ -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 = "<open-swe-plan-message>";
const PLAN_MESSAGE_CLOSE_TAG = "</open-swe-plan-message>";
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<void> {
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);
}

View file

@ -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<GitHubIssueComment | null> {
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;
}
}