mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
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
This commit is contained in:
parent
30b0bfb639
commit
c54e7d725f
27 changed files with 2096 additions and 265 deletions
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
62
apps/open-swe/src/routes/github/get-pr-context.int.test.ts
Normal file
62
apps/open-swe/src/routes/github/get-pr-context.int.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
117
apps/open-swe/src/routes/github/issue-labeled.ts
Normal file
117
apps/open-swe/src/routes/github/issue-labeled.ts
Normal file
|
|
@ -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<GraphConfig["configurable"]> = 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);
|
||||
}
|
||||
|
|
@ -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<string, any> | null => {
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createDevMetadataComment = (runId: string, threadId: string) => {
|
||||
return `<details>
|
||||
<summary>Dev Metadata</summary>
|
||||
${JSON.stringify(
|
||||
{
|
||||
runId,
|
||||
threadId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</details>`;
|
||||
};
|
||||
|
||||
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<string, any> = {
|
||||
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<BlankEnv, "/webhooks/github", BlankInput>,
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
159
apps/open-swe/src/routes/github/pr-webhook-handler-base.ts
Normal file
159
apps/open-swe/src/routes/github/pr-webhook-handler-base.ts
Normal file
|
|
@ -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<PRWebhookContext | null> {
|
||||
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<GraphConfig["configurable"]> {
|
||||
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;
|
||||
}
|
||||
344
apps/open-swe/src/routes/github/prompts.ts
Normal file
344
apps/open-swe/src/routes/github/prompts.ts
Normal file
|
|
@ -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 = `<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<context-overview>
|
||||
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 <trigger-review> 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.
|
||||
</context-overview>
|
||||
|
||||
<pull-request-data>
|
||||
Here is the data on the pull request you're resolving the review for:
|
||||
|
||||
{PR_DATA}
|
||||
|
||||
</pull-request-data>
|
||||
|
||||
<linked-issues>
|
||||
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}
|
||||
|
||||
</linked-issues>
|
||||
|
||||
<pull-request-comments>
|
||||
Here are all of the comments (if any) which were left on the pull request.
|
||||
|
||||
{PR_COMMENTS}
|
||||
|
||||
</pull-request-comments>
|
||||
|
||||
<pull-request-reviews>
|
||||
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}
|
||||
|
||||
</pull-request-reviews>
|
||||
|
||||
<trigger-review>
|
||||
Here is the review you were tagged in. Ensure you focus on resolving whatever request was made in the review.
|
||||
|
||||
{TRIGGER_COMMENT}
|
||||
|
||||
</trigger-review>
|
||||
|
||||
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 = `<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<context-overview>
|
||||
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 <trigger-review-comment> 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.
|
||||
</context-overview>
|
||||
|
||||
<pull-request-data>
|
||||
Here is the data on the pull request you're resolving the review for:
|
||||
|
||||
{PR_DATA}
|
||||
|
||||
</pull-request-data>
|
||||
|
||||
<linked-issues>
|
||||
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}
|
||||
|
||||
</linked-issues>
|
||||
|
||||
<pull-request-comments>
|
||||
Here are all of the comments (if any) which were left on the pull request.
|
||||
|
||||
{PR_COMMENTS}
|
||||
|
||||
</pull-request-comments>
|
||||
|
||||
<pull-request-reviews>
|
||||
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}
|
||||
|
||||
</pull-request-reviews>
|
||||
|
||||
<trigger-review-comment>
|
||||
Here is the review comment you were tagged in. Ensure you focus on resolving whatever request was made in the comment.
|
||||
|
||||
{TRIGGER_COMMENT}
|
||||
|
||||
</trigger-review-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 = `<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<context-overview>
|
||||
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 <trigger-comment> 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.
|
||||
</context-overview>
|
||||
|
||||
<pull-request-data>
|
||||
Here is the data on the pull request you're resolving the review for:
|
||||
|
||||
{PR_DATA}
|
||||
|
||||
</pull-request-data>
|
||||
|
||||
<linked-issues>
|
||||
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}
|
||||
|
||||
</linked-issues>
|
||||
|
||||
<pull-request-reviews>
|
||||
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}
|
||||
|
||||
</pull-request-reviews>
|
||||
|
||||
|
||||
<pull-request-comments>
|
||||
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}
|
||||
|
||||
</pull-request-comments>
|
||||
|
||||
<trigger-comment>
|
||||
Here is the comment you were tagged in. Ensure you focus on resolving whatever request was made in the comment.
|
||||
|
||||
{TRIGGER_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 number="${issue.number}">
|
||||
<state>${issue.state}</state>
|
||||
<title>${issue.title}</title>
|
||||
<body>${issue.body ?? "No body"}</body>
|
||||
</issue>
|
||||
`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatPRCommentsPrompt(comments: SimplePullRequestComment[]): string {
|
||||
if (!comments.length) {
|
||||
return "No comments";
|
||||
}
|
||||
|
||||
return comments
|
||||
.map(
|
||||
(comment) => `<comment id="${comment.id}">
|
||||
<author>${comment.author}</author>
|
||||
<body>${comment.body}</body>
|
||||
</comment>
|
||||
`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatPRReviewsPrompt(reviews: SimplePullRequestReview[]): string {
|
||||
if (!reviews.length) {
|
||||
return "No reviews";
|
||||
}
|
||||
|
||||
return reviews
|
||||
.map(
|
||||
(review) => `<review id="${review.id}">
|
||||
<author>${review.author}</author>
|
||||
<body>${review.body ?? "No review body"}</body>
|
||||
<state>${review.state}</state>
|
||||
|
||||
${review.reviewComments.map(
|
||||
(comment) => `<review-comment id="${comment.id}">
|
||||
<body>${comment.body ?? "No review comment body"}</body>
|
||||
<path>${comment.path}</path>
|
||||
<line>${comment.line}</line>
|
||||
<diff-hunk>
|
||||
${comment.diff_hunk}
|
||||
</diff-hunk>
|
||||
</review-comment>`,
|
||||
)}
|
||||
|
||||
</review>
|
||||
`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatPRDataPrompt(prData: SimplePullRequest): string {
|
||||
return `<title>\n${prData.title}</title>
|
||||
<body>${prData.body}</body>
|
||||
<author>${prData.author}</author>
|
||||
<state>${prData.state}</state>
|
||||
|
||||
<head-ref>${prData.head.ref}</head-ref>`;
|
||||
}
|
||||
|
||||
function formatTriggerComment(comment: SimpleTriggerComment): string {
|
||||
return `<author>${comment.author}</author>
|
||||
<body>${comment.body}</body>
|
||||
${comment.path ? `<path>${comment.path}</path>` : ""}
|
||||
${comment.line ? `<line>${comment.line}</line>` : ""}
|
||||
${comment.diff_hunk ? `<diff-hunk>${comment.diff_hunk}</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));
|
||||
}
|
||||
124
apps/open-swe/src/routes/github/pull-request-comment.ts
Normal file
124
apps/open-swe/src/routes/github/pull-request-comment.ts
Normal file
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
return prCommentHandler.handlePullRequestComment(payload);
|
||||
}
|
||||
120
apps/open-swe/src/routes/github/pull-request-review-comment.ts
Normal file
120
apps/open-swe/src/routes/github/pull-request-review-comment.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
return prReviewCommentHandler.handlePullRequestReviewComment(payload);
|
||||
}
|
||||
114
apps/open-swe/src/routes/github/pull-request-review.ts
Normal file
114
apps/open-swe/src/routes/github/pull-request-review.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
return prReviewHandler.handlePullRequestReview(payload);
|
||||
}
|
||||
68
apps/open-swe/src/routes/github/types.ts
Normal file
68
apps/open-swe/src/routes/github/types.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
93
apps/open-swe/src/routes/github/unified-webhook.ts
Normal file
93
apps/open-swe/src/routes/github/unified-webhook.ts
Normal file
|
|
@ -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<string, any> | 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<BlankEnv, "/webhooks/github", BlankInput>,
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
256
apps/open-swe/src/routes/github/utils.ts
Normal file
256
apps/open-swe/src/routes/github/utils.ts
Normal file
|
|
@ -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 `<details>
|
||||
<summary>Dev Metadata</summary>
|
||||
${JSON.stringify(
|
||||
{
|
||||
runId,
|
||||
threadId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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<number, any[]>();
|
||||
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<GraphConfig["configurable"]>;
|
||||
}): 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}`;
|
||||
}
|
||||
155
apps/open-swe/src/routes/github/webhook-handler-base.ts
Normal file
155
apps/open-swe/src/routes/github/webhook-handler-base.ts
Normal file
|
|
@ -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<GraphConfig["configurable"]>;
|
||||
}
|
||||
|
||||
export interface CommentConfiguration {
|
||||
issueNumber: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class WebhookHandlerBase {
|
||||
protected logger: ReturnType<typeof createLogger>;
|
||||
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<WebhookHandlerContext | null> {
|
||||
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<void> {
|
||||
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<string, any> = {},
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
76
apps/open-swe/src/routes/github/webhook-messages.ts
Normal file
76
apps/open-swe/src/routes/github/webhook-messages.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
134
apps/open-swe/src/tools/reply-to-review-comment.ts
Normal file
134
apps/open-swe/src/tools/reply-to-review-comment.ts
Normal file
|
|
@ -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<GraphState, "targetRepository">,
|
||||
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<GraphState, "targetRepository">,
|
||||
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<GraphState, "targetRepository">,
|
||||
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;
|
||||
}
|
||||
|
|
@ -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"]];
|
||||
|
|
|
|||
|
|
@ -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<GitHubReviewComment | null> {
|
||||
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<GitHubIssueComment | null> {
|
||||
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<GitHubIssueComment | null> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue