feat: trigger runs from GH issues (#324)

* feat: trigger runs from GH issues

* cr

* working

* cr

* done

* cr

* cr

* cr

* cr

* diff label for dev vs prod

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2025-06-30 16:43:09 -06:00 • committed by GitHub
parent 93983d3c0a
commit ba0690caa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 697 additions and 101 deletions

View file

@ -29,9 +29,13 @@
"@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.85",
"@langchain/openai": "^0.5.10",
"@octokit/app": "^16.0.1",
"@octokit/core": "^7.0.2",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks": "^14.0.2",
"@open-swe/shared": "*",
"diff": "^8.0.1",
"hono": "^4.8.3",
"langchain": "^0.3.26",
"langsmith": "^0.3.29",
"uuid": "^11.0.5",

View file

@ -2,6 +2,12 @@
import { spawn } from "child_process";
import * as path from "path";
const REQUIRED_ENV = {
GITHUB_APP_ID: "test",
GITHUB_APP_PRIVATE_KEY: "test",
GITHUB_WEBHOOK_SECRET: "test",
};
/**
* Checks if the development server starts successfully.
* This script starts the dev server and monitors the output for 30 seconds
@ -18,6 +24,7 @@ function checkDevServer(): Promise<void> {
cwd: targetCwd,
shell: true,
stdio: "pipe",
env: REQUIRED_ENV,
});
let errorDetected = false;

View file

@ -7,6 +7,8 @@ import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "@open-swe/shared/constants";
import {
BaseMessage,
@ -221,6 +223,10 @@ export async function classifyMessage(
[GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "",
[GITHUB_INSTALLATION_TOKEN_COOKIE]:
config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "",
[GITHUB_USER_ID_HEADER]:
config.configurable?.[GITHUB_USER_ID_HEADER] ?? "",
[GITHUB_USER_LOGIN_HEADER]:
config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "",
},
});

View file

@ -8,6 +8,8 @@ import { createIssueTitleAndBodyFromMessages } from "../utils/generate-issue-fie
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "@open-swe/shared/constants";
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import { createIssue } from "../../../utils/github/api.js";
@ -75,6 +77,10 @@ ${ISSUE_CONTENT_CLOSE_TAG}`,
[GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "",
[GITHUB_INSTALLATION_TOKEN_COOKIE]:
config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "",
[GITHUB_USER_ID_HEADER]:
config.configurable?.[GITHUB_USER_ID_HEADER] ?? "",
[GITHUB_USER_LOGIN_HEADER]:
config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "",
},
});

View file

@ -8,6 +8,8 @@ import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "@open-swe/shared/constants";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { getBranchName } from "../../../utils/github/git.js";
@ -27,6 +29,10 @@ export async function startPlanner(
[GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "",
[GITHUB_INSTALLATION_TOKEN_COOKIE]:
config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "",
[GITHUB_USER_ID_HEADER]:
config.configurable?.[GITHUB_USER_ID_HEADER] ?? "",
[GITHUB_USER_LOGIN_HEADER]:
config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "",
},
});

View file

@ -12,6 +12,8 @@ import { getUserRequest } from "../../../utils/user-request.js";
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
PLAN_INTERRUPT_ACTION_TITLE,
PLAN_INTERRUPT_DELIMITER,
} from "@open-swe/shared/constants";
@ -70,6 +72,10 @@ export async function interruptProposedPlan(
[GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "",
[GITHUB_INSTALLATION_TOKEN_COOKIE]:
config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "",
[GITHUB_USER_ID_HEADER]:
config.configurable?.[GITHUB_USER_ID_HEADER] ?? "",
[GITHUB_USER_LOGIN_HEADER]:
config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "",
},
});

View file

@ -0,0 +1,6 @@
import { Hono } from "hono";
import { issueWebhookHandler } from "./github/issue-webhook.js";
export const app = new Hono();
app.post("/webhooks/github", issueWebhookHandler);

View file

@ -0,0 +1,195 @@
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_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
MANAGER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { encryptGitHubToken } from "@open-swe/shared/crypto";
import { HumanMessage } from "@langchain/core/messages";
import { getOpenSWELabel } from "../../utils/github/label.js";
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 getOpenSweAppUrl = (threadId: string) => {
if (!process.env.OPEN_SWE_APP_URL) {
return "";
}
try {
const baseUrl = new URL(process.env.OPEN_SWE_APP_URL);
baseUrl.pathname = `/chat/${threadId}`;
return baseUrl.toString();
} catch {
return "";
}
};
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 };
};
webhooks.on("issues.labeled", async ({ payload }) => {
if (!process.env.GITHUB_TOKEN_ENCRYPTION_KEY) {
throw new Error(
"GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required",
);
}
if (payload.label?.name !== getOpenSWELabel()) {
return;
}
logger.info(`'open-swe' label added to issue #${payload.issue.number}`);
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,
};
const langGraphClient = createLangGraphClient({
defaultHeaders: {
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptGitHubToken(
token,
process.env.GITHUB_TOKEN_ENCRYPTION_KEY,
),
[GITHUB_USER_ID_HEADER]: issueData.userId.toString(),
[GITHUB_USER_LOGIN_HEADER]: issueData.userLogin,
},
});
const threadId = uuidv4();
const run = await langGraphClient.runs.create(threadId, MANAGER_GRAPH_ID, {
input: {
messages: [
new HumanMessage({
id: uuidv4(),
content: `**${issueData.issueTitle}**\n\n${issueData.issueBody}`,
additional_kwargs: {
isOriginalIssue: true,
githubIssueId: issueData.issueNumber,
},
}),
],
githubIssueId: issueData.issueNumber,
targetRepository: {
owner: issueData.owner,
repo: issueData.repo,
},
},
config: {
recursion_limit: 400,
},
ifNotExists: "create",
streamResumable: true,
streamMode: ["values", "messages", "custom"],
});
logger.info("Created new run from GitHub issue.", {
thread_id: threadId,
run_id: run.run_id,
issue_number: issueData.issueNumber,
owner: issueData.owner,
repo: issueData.repo,
user_id: issueData.userId,
user_login: issueData.userLogin,
});
logger.info("Creating comment...");
const appUrl = getOpenSweAppUrl(threadId);
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${appUrl ? `View run in Open SWE [here](${appUrl})` : ""}`,
},
);
} 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 });
}
}

View file

@ -2,38 +2,17 @@ import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth";
import {
verifyGithubUser,
GithubUser,
verifyGithubUserId,
} from "@open-swe/shared/github/verify-user";
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "@open-swe/shared/constants";
import { decryptGitHubToken } from "@open-swe/shared/crypto";
const STUDIO_USER_ID = "langgraph-studio-user";
// Helper function to check if user is studio user
const isStudioUser = (userIdentity: string): boolean => {
return userIdentity === STUDIO_USER_ID;
};
// Helper function for operations that only need owner filtering
const createOwnerFilter = (user: { identity: string }) => {
if (isStudioUser(user.identity)) {
return;
}
return { owner: user.identity };
};
// Helper function for create operations that set metadata
const createWithOwnerMetadata = (value: any, user: { identity: string }) => {
if (isStudioUser(user.identity)) {
return;
}
value.metadata ??= {};
value.metadata.owner = user.identity;
return { owner: user.identity };
};
import { verifyGitHubWebhookOrThrow } from "./github.js";
import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js";
export const auth = new Auth()
.authenticate(async (request: Request) => {
@ -45,6 +24,13 @@ export const auth = new Auth()
display_name: "CORS Preflight",
};
}
const ghSecretHashHeader = request.headers.get("X-Hub-Signature-256");
if (ghSecretHashHeader) {
// This will either return a valid user, or throw an error
return await verifyGitHubWebhookOrThrow(request);
}
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error(
@ -52,13 +38,6 @@ export const auth = new Auth()
);
}
// Parse Authorization header
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
if (!encryptedAccessToken) {
throw new HTTPException(401, {
message: "GitHub access token header missing",
});
}
// We don't do anything with this token right now, but still confirm it
// exists as it will cause issues later on if it's not present.
const encryptedInstallationToken = request.headers.get(
@ -70,24 +49,37 @@ export const auth = new Auth()
});
}
// Validate GitHub access token
let user: GithubUser | undefined;
try {
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
if (!encryptedAccessToken) {
// If there isn't a user access token, check to see if the user info is in headers.
// This would indicate a bot created the request.
const userIdHeader = request.headers.get(GITHUB_USER_ID_HEADER);
const userLoginHeader = request.headers.get(GITHUB_USER_LOGIN_HEADER);
if (!userIdHeader || !userLoginHeader) {
throw new HTTPException(401, {
message: "Github-User-Id or Github-User-Login header missing",
});
}
user = await verifyGithubUserId(
decryptGitHubToken(encryptedInstallationToken, encryptionKey),
Number(userIdHeader),
userLoginHeader,
);
} else {
// Ensure we decrypt the token before passing to the verification function.
user = await verifyGithubUser(
decryptGitHubToken(encryptedAccessToken, encryptionKey),
);
if (!user) {
throw new HTTPException(401, {
message:
"Invalid GitHub token or user is not a member of the required organization.",
});
}
} catch (e: any) {
}
if (!user) {
throw new HTTPException(401, {
message: `Authentication error: ${e.message}`,
message: "User not found",
});
}
return {
identity: user.id.toString(),
is_authenticated: true,

View file

@ -0,0 +1,58 @@
import { HTTPException } from "@langchain/langgraph-sdk/auth";
import { Webhooks } from "@octokit/webhooks";
import { createLogger, LogLevel } from "../utils/logger.js";
const logger = createLogger(LogLevel.INFO, "GitHubWebhookAuth");
export async function verifyGitHubWebhookOrThrow(request: Request) {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error("Missing GITHUB_WEBHOOK_SECRET environment variable.");
}
const webhooks = new Webhooks({
secret,
});
const requestClone = request.clone();
const githubDeliveryHeader = requestClone.headers.get("x-github-delivery");
const githubEventHeader = requestClone.headers.get("x-github-event");
const githubSignatureHeader = requestClone.headers.get("x-hub-signature-256");
if (!githubDeliveryHeader || !githubEventHeader || !githubSignatureHeader) {
throw new HTTPException(401, {
message: "Missing GitHub webhook headers.",
});
}
const payload = await requestClone.text();
const signature = await webhooks.sign(payload);
const isValid = await webhooks.verify(payload, signature);
if (!isValid) {
logger.error("Failed to verify GitHub webhook");
throw new HTTPException(401, {
message: "Invalid GitHub webhook signature.",
});
}
return {
identity: "x-internal-github-bot",
is_authenticated: true,
display_name: "GitHub Bot",
permissions: [
"threads:create",
"threads:create_run",
"threads:read",
"threads:delete",
"threads:update",
"threads:search",
"assistants:create",
"assistants:read",
"assistants:delete",
"assistants:update",
"assistants:search",
"deployments:read",
"deployments:search",
"store:access",
],
};
}

View file

@ -0,0 +1,28 @@
const STUDIO_USER_ID = "langgraph-studio-user";
// Helper function to check if user is studio user
export function isStudioUser(userIdentity: string): boolean {
return userIdentity === STUDIO_USER_ID;
}
// Helper function for operations that only need owner filtering
export function createOwnerFilter(user: { identity: string }) {
if (isStudioUser(user.identity)) {
return;
}
return { owner: user.identity };
}
// Helper function for create operations that set metadata
export function createWithOwnerMetadata(
value: any,
user: { identity: string },
) {
if (isStudioUser(user.identity)) {
return;
}
value.metadata ??= {};
value.metadata.owner = user.identity;
return { owner: user.identity };
}

View file

@ -0,0 +1,46 @@
import { App } from "@octokit/app";
import { Octokit } from "@octokit/core";
export class GitHubApp {
app: App;
constructor() {
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET;
if (!appId || !privateKey || !webhookSecret) {
throw new Error(
"GitHub App ID, Private Key, or Webhook Secret is not configured.",
);
}
this.app = new App({
appId,
privateKey,
webhooks: {
secret: webhookSecret,
},
});
}
async getInstallationOctokit(installationId: number): Promise<Octokit> {
return await this.app.getInstallationOctokit(installationId);
}
async getInstallationAccessToken(installationId: number): Promise<{
token: string;
expiresAt: string;
}> {
const octokit = await this.app.getInstallationOctokit(installationId);
// The installation access token is available on the auth property
const auth = (await octokit.auth({
type: "installation",
})) as any;
return {
token: auth.token,
expiresAt: auth.expiresAt,
};
}
}

View file

@ -15,9 +15,9 @@ export function getGitHubTokensFromConfig(config: GraphConfig): {
const encryptedGitHubToken = config.configurable[GITHUB_TOKEN_COOKIE];
const encryptedInstallationToken =
config.configurable[GITHUB_INSTALLATION_TOKEN_COOKIE];
if (!encryptedGitHubToken || !encryptedInstallationToken) {
if (!encryptedInstallationToken) {
throw new Error(
"Missing required x-github-access-token or x-github-installation-token in configuration.",
`Missing required ${GITHUB_INSTALLATION_TOKEN_COOKIE} in configuration.`,
);
}
@ -30,10 +30,9 @@ export function getGitHubTokensFromConfig(config: GraphConfig): {
}
// Decrypt the GitHub token
const githubAccessToken = decryptGitHubToken(
encryptedGitHubToken,
encryptionKey,
);
const githubAccessToken = encryptedGitHubToken
? decryptGitHubToken(encryptedGitHubToken, encryptionKey)
: "";
const githubInstallationToken = decryptGitHubToken(
encryptedInstallationToken,
encryptionKey,

View file

@ -1,6 +1,7 @@
import { Octokit } from "@octokit/rest";
import { createLogger, LogLevel } from "../logger.js";
import { GitHubIssue, GitHubIssueComment, GitHubPullRequest } from "./types.js";
import { getOpenSWELabel } from "./label.js";
const logger = createLogger(LogLevel.INFO, "GitHub-API");
@ -141,7 +142,7 @@ export async function createPullRequest({
owner,
repo,
issue_number: pullRequest.number,
labels: ["open-swe"],
labels: [getOpenSWELabel()],
});
logger.info("Added 'open-swe' label to pull request", {
pullRequestNumber: pullRequest.number,

View file

@ -0,0 +1,6 @@
/**
* @returns "open-swe" or "open-swe-dev" based on the NODE_ENV.
*/
export function getOpenSWELabel() {
return process.env.NODE_ENV === "production" ? "open-swe" : "open-swe-dev";
}

View file

@ -5,6 +5,7 @@ import { ThreadViewLoading } from "@/components/v2/thread-view-loading";
import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types";
import { useThreads } from "@/hooks/useThreads";
import { useStream } from "@langchain/langgraph-sdk/react";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { useRouter } from "next/navigation";
@ -24,14 +25,12 @@ export default function ThreadPage({
const { thread_id } = use(params);
const stream = useStream<ManagerGraphState>({
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
assistantId: process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID ?? "",
assistantId: MANAGER_GRAPH_ID,
threadId: thread_id,
reconnectOnMount: true,
});
const { threads, threadsLoading } = useThreads<GraphState>(
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID,
);
const { threads, threadsLoading } = useThreads<GraphState>(MANAGER_GRAPH_ID);
// Find the thread by ID
const thread = threads.find((t) => t.thread_id === thread_id);

View file

@ -7,11 +7,10 @@ import { GitHubAppProvider } from "@/providers/GitHubApp";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { Toaster } from "@/components/ui/sonner";
import { Suspense } from "react";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
export default function ChatPage() {
const { threads, threadsLoading } = useThreads<GraphState>(
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID,
);
const { threads, threadsLoading } = useThreads<GraphState>(MANAGER_GRAPH_ID);
// Convert Thread objects to ThreadDisplayInfo for UI
const displayThreads: ThreadDisplayInfo[] = threads.map(threadToDisplayInfo);

View file

@ -12,14 +12,13 @@ import { useThreads } from "@/hooks/useThreads";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { ThreadCard, ThreadCardLoading } from "@/components/v2/thread-card";
import { ThemeToggle } from "@/components/theme-toggle";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
type FilterStatus = "all" | "running" | "completed" | "failed" | "pending";
export default function AllThreadsPage() {
const router = useRouter();
const { threads, threadsLoading } = useThreads<GraphState>(
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID,
);
const { threads, threadsLoading } = useThreads<GraphState>(MANAGER_GRAPH_ID);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");

View file

@ -27,6 +27,7 @@ import { GitHubInstallationBanner } from "../github/installation-banner";
import { QuickActions } from "./quick-actions";
import { useState } from "react";
import { GitHubLogoutButton } from "../github/github-oauth-button";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
interface DefaultViewProps {
threads: ThreadDisplayInfo[];
@ -37,8 +38,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
const router = useRouter();
const [quickActionPrompt, setQuickActionPrompt] = useState("");
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
const assistantId: string | undefined =
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID ?? "";
const assistantId: string | undefined = MANAGER_GRAPH_ID;
const {
contentBlocks,
setContentBlocks,
@ -49,8 +49,8 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
handlePaste,
} = useFileUpload();
if (!apiUrl || !assistantId) {
return <div>Missing API URL or Assistant ID</div>;
if (!apiUrl) {
return <div>Missing API URL environment variable</div>;
}
return (

View file

@ -14,6 +14,7 @@ import { GraphState } from "@open-swe/shared/open-swe/types";
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
import { toast } from "sonner";
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
interface TerminalInputProps {
placeholder?: string;
@ -51,14 +52,7 @@ export function TerminalInput({
});
const handleSend = async () => {
const assistantId = process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID;
if (!assistantId) {
toast.error("No assistant ID found", {
richColors: true,
closeButton: true,
});
return;
}
const assistantId = MANAGER_GRAPH_ID;
if (!selectedRepository) {
toast.error("Please select a repository first", {
richColors: true,

View file

@ -15,7 +15,11 @@ import { GraphState } from "@open-swe/shared/open-swe/types";
import { ActionsRenderer } from "./actions-renderer";
import { ThemeToggle } from "../theme-toggle";
import { HumanMessage } from "@langchain/core/messages";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import {
DO_NOT_RENDER_ID_PREFIX,
PROGRAMMER_GRAPH_ID,
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { StickToBottom } from "use-stick-to-bottom";
import {
StickyToBottomContent,
@ -23,9 +27,6 @@ import {
} from "../../utils/scroll-utils";
import { ManagerChat } from "./manager-chat";
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_ASSISTANT_ID;
const PLANNER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PLANNER_ASSISTANT_ID;
interface ThreadViewProps {
stream: ReturnType<typeof useStream<ManagerGraphState>>;
displayThread: ThreadDisplayInfo;
@ -153,23 +154,17 @@ export function ThreadView({
<TabsContent value="planner">
<Card className="border-border bg-card px-0 py-4 dark:bg-gray-950">
<CardContent className="space-y-2 p-3 pt-0">
{plannerThreadId &&
plannerRunId &&
PLANNER_ASSISTANT_ID && (
<ActionsRenderer<PlannerGraphState>
graphId={PLANNER_ASSISTANT_ID}
threadId={plannerThreadId}
runId={plannerRunId}
setProgrammerSession={setProgrammerSession}
programmerSession={programmerSession}
setSelectedTab={setSelectedTab}
/>
)}
{!(
plannerThreadId &&
plannerRunId &&
PLANNER_ASSISTANT_ID
) && (
{plannerThreadId && plannerRunId && (
<ActionsRenderer<PlannerGraphState>
graphId={PLANNER_GRAPH_ID}
threadId={plannerThreadId}
runId={plannerRunId}
setProgrammerSession={setProgrammerSession}
programmerSession={programmerSession}
setSelectedTab={setSelectedTab}
/>
)}
{!(plannerThreadId && plannerRunId) && (
<div className="flex items-center justify-center gap-2 py-8">
<Clock className="text-muted-foreground size-4" />
<span className="text-muted-foreground text-sm">
@ -183,9 +178,9 @@ export function ThreadView({
<TabsContent value="programmer">
<Card className="border-border bg-card px-0 py-4 dark:bg-gray-950">
<CardContent className="space-y-2 p-3 pt-0">
{programmerSession && PROGRAMMER_ASSISTANT_ID && (
{programmerSession && (
<ActionsRenderer<GraphState>
graphId={PROGRAMMER_ASSISTANT_ID}
graphId={PROGRAMMER_GRAPH_ID}
threadId={programmerSession.threadId}
runId={programmerSession.runId}
/>

View file

@ -14,6 +14,7 @@ import {
CustomNodeEvent,
isCustomNodeEvent,
} from "@open-swe/shared/open-swe/custom-node-events";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
const useTypedStream = useStream<
GraphState,
@ -37,18 +38,15 @@ const StreamSession = ({ children }: { children: ReactNode }) => {
const [customEvents, setCustomEvents] = useState<CustomNodeEvent[]>([]);
const { refreshThreads } = useThreadsContext();
if (
!process.env.NEXT_PUBLIC_API_URL ||
!process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID
) {
if (!process.env.NEXT_PUBLIC_API_URL) {
throw new Error(
"Both NEXT_PUBLIC_API_URL and NEXT_PUBLIC_MANAGER_ASSISTANT_ID environment variables must be defined.",
"NEXT_PUBLIC_API_URL environment variable must be defined.",
);
}
const streamValue = useTypedStream({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID,
assistantId: MANAGER_GRAPH_ID,
reconnectOnMount: true,
threadId: threadId ?? null,
onCustomEvent: (event, options) => {

View file

@ -9,6 +9,9 @@
"dependencies": ["./apps/open-swe"],
"auth": {
"path": "./apps/open-swe/src/security/auth.ts:auth"
},
"http": {
"app": "./apps/open-swe/src/routes/app.ts:app"
}
}

View file

@ -12,3 +12,10 @@ export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";
export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id";
export const GITHUB_TOKEN_TYPE_COOKIE = "github_token_type";
export const MANAGER_GRAPH_ID = "manager";
export const PLANNER_GRAPH_ID = "planner";
export const PROGRAMMER_GRAPH_ID = "programmer";
export const GITHUB_USER_ID_HEADER = "x-github-user-id";
export const GITHUB_USER_LOGIN_HEADER = "x-github-user-login";

View file

@ -26,3 +26,31 @@ export async function verifyGithubUser(
return undefined;
}
}
/**
* Verifies a GitHub user ID using the app installation token. Checks that the provided
* user ID is valid, and the provided login matches the user's login.
* @param installationToken The GitHub installation token.
* @param userId The GitHub user ID.
* @param userLogin The GitHub user login.
* @returns A promise that resolves with the user object if valid, otherwise undefined.
*/
export async function verifyGithubUserId(
installationToken: string,
userId: number,
userLogin: string,
): Promise<GithubUser | undefined> {
try {
const octokit = new Octokit({ auth: installationToken });
const { data: user } = await octokit.users.getById({ account_id: userId });
if (!user || !user.login) {
return undefined;
}
if (user.login !== userLogin) {
return undefined;
}
return user;
} catch {
return undefined;
}
}

View file

@ -16,6 +16,8 @@ import {
import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "../constants.js";
import { withLangGraph } from "@langchain/langgraph/zod";
import { BaseMessage } from "@langchain/core/messages";
@ -395,6 +397,16 @@ export const GraphConfigurationMetadata: {
type: "hidden",
},
},
[GITHUB_USER_ID_HEADER]: {
x_open_swe_ui_config: {
type: "hidden",
},
},
[GITHUB_USER_LOGIN_HEADER]: {
x_open_swe_ui_config: {
type: "hidden",
},
},
};
export const GraphConfiguration = z.object({
@ -550,6 +562,20 @@ export const GraphConfiguration = z.object({
.langgraph.metadata(
GraphConfigurationMetadata[GITHUB_INSTALLATION_TOKEN_COOKIE],
),
/**
* The user's GitHub ID. Required when creating runs triggered by a bot (e.g. GitHub issue)
*/
[GITHUB_USER_ID_HEADER]: z
.string()
.optional()
.langgraph.metadata(GraphConfigurationMetadata[GITHUB_USER_ID_HEADER]),
/**
* The user's GitHub login. Required when creating runs triggered by a bot (e.g. GitHub issue)
*/
[GITHUB_USER_LOGIN_HEADER]: z
.string()
.optional()
.langgraph.metadata(GraphConfigurationMetadata[GITHUB_USER_LOGIN_HEADER]),
});
export type GraphConfig = LangGraphRunnableConfig<

182
yarn.lock
View file

@ -2087,6 +2087,75 @@ __metadata:
languageName: node
linkType: hard
"@octokit/app@npm:^16.0.1":
version: 16.0.1
resolution: "@octokit/app@npm:16.0.1"
dependencies:
"@octokit/auth-app": ^8.0.1
"@octokit/auth-unauthenticated": ^7.0.1
"@octokit/core": ^7.0.2
"@octokit/oauth-app": ^8.0.1
"@octokit/plugin-paginate-rest": ^13.0.0
"@octokit/types": ^14.0.0
"@octokit/webhooks": ^14.0.0
checksum: a6c8fb0d1fda7da9022bb04b3e4baf3e30320eff03de63cdf34a0bf90a1afd471705063c909137f308b6bec359c6762e9a0f1303a53853770e06dabaa6f6460d
languageName: node
linkType: hard
"@octokit/auth-app@npm:^8.0.1":
version: 8.0.1
resolution: "@octokit/auth-app@npm:8.0.1"
dependencies:
"@octokit/auth-oauth-app": ^9.0.1
"@octokit/auth-oauth-user": ^6.0.0
"@octokit/request": ^10.0.2
"@octokit/request-error": ^7.0.0
"@octokit/types": ^14.0.0
toad-cache: ^3.7.0
universal-github-app-jwt: ^2.2.0
universal-user-agent: ^7.0.0
checksum: ca7ede162bfec0602f3578775f81a5c2f2b8c691ec51ae619fb0943b23952da4842495147333d8938faef01fa1dcc43885df9da320140f774a110ded0042ddec
languageName: node
linkType: hard
"@octokit/auth-oauth-app@npm:^9.0.1":
version: 9.0.1
resolution: "@octokit/auth-oauth-app@npm:9.0.1"
dependencies:
"@octokit/auth-oauth-device": ^8.0.1
"@octokit/auth-oauth-user": ^6.0.0
"@octokit/request": ^10.0.2
"@octokit/types": ^14.0.0
universal-user-agent: ^7.0.0
checksum: cf6e6b87cc04638c88b1eda5d43a41fc524262e89ad73d5ad19ac1ae63e35727d867966a2e036bbf9699da9f7256d50b2d735a2e3d2fff84610d44a5107f5a86
languageName: node
linkType: hard
"@octokit/auth-oauth-device@npm:^8.0.1":
version: 8.0.1
resolution: "@octokit/auth-oauth-device@npm:8.0.1"
dependencies:
"@octokit/oauth-methods": ^6.0.0
"@octokit/request": ^10.0.2
"@octokit/types": ^14.0.0
universal-user-agent: ^7.0.0
checksum: 074d1fb0b39efa0f440cb9e2b70bab920920d39d43f05210764a7e1f4c0afac71c7d9c5bfbce62e979d1adf886c6b9788a99a7617098970790e87963125d5264
languageName: node
linkType: hard
"@octokit/auth-oauth-user@npm:^6.0.0":
version: 6.0.0
resolution: "@octokit/auth-oauth-user@npm:6.0.0"
dependencies:
"@octokit/auth-oauth-device": ^8.0.1
"@octokit/oauth-methods": ^6.0.0
"@octokit/request": ^10.0.2
"@octokit/types": ^14.0.0
universal-user-agent: ^7.0.0
checksum: 663b856fc3dd7047d08220bef5444d05ba748b1cb34bca48f485fc467283fdac64c3cdf5a21eae503058b4372ae0092d705b3ee59583715393229f9028370bb2
languageName: node
linkType: hard
"@octokit/auth-token@npm:^6.0.0":
version: 6.0.0
resolution: "@octokit/auth-token@npm:6.0.0"
@ -2094,6 +2163,16 @@ __metadata:
languageName: node
linkType: hard
"@octokit/auth-unauthenticated@npm:^7.0.1":
version: 7.0.1
resolution: "@octokit/auth-unauthenticated@npm:7.0.1"
dependencies:
"@octokit/request-error": ^7.0.0
"@octokit/types": ^14.0.0
checksum: 825eaf2c2df66bd307984098440caac24d50f7e5c3e56bddaf96f6b46814be28b4d550de44786c1c8bcb744a962ade1fd92eecf3a59e422e776adc1344e6268f
languageName: node
linkType: hard
"@octokit/core@npm:^7.0.2":
version: 7.0.2
resolution: "@octokit/core@npm:7.0.2"
@ -2130,6 +2209,41 @@ __metadata:
languageName: node
linkType: hard
"@octokit/oauth-app@npm:^8.0.1":
version: 8.0.1
resolution: "@octokit/oauth-app@npm:8.0.1"
dependencies:
"@octokit/auth-oauth-app": ^9.0.1
"@octokit/auth-oauth-user": ^6.0.0
"@octokit/auth-unauthenticated": ^7.0.1
"@octokit/core": ^7.0.2
"@octokit/oauth-authorization-url": ^8.0.0
"@octokit/oauth-methods": ^6.0.0
"@types/aws-lambda": ^8.10.83
universal-user-agent: ^7.0.0
checksum: ef03e4fe7da34930aa14b9170a0f53d278f398b30e5f2c033bb8836f64d1226989f2bb25456cf6b234ae3ab84e30b6d329706ca0604e0efff1ad81ae5f6b4749
languageName: node
linkType: hard
"@octokit/oauth-authorization-url@npm:^8.0.0":
version: 8.0.0
resolution: "@octokit/oauth-authorization-url@npm:8.0.0"
checksum: 8f7431f986ed87e3980b6647c2af7a4df383116e0b4f37a17d10708bb4b7d93dcb2de6f05228539a77f050c0786498f2a62ce71243299d1051e54746484b07f1
languageName: node
linkType: hard
"@octokit/oauth-methods@npm:^6.0.0":
version: 6.0.0
resolution: "@octokit/oauth-methods@npm:6.0.0"
dependencies:
"@octokit/oauth-authorization-url": ^8.0.0
"@octokit/request": ^10.0.2
"@octokit/request-error": ^7.0.0
"@octokit/types": ^14.0.0
checksum: f17ab37869b13bf832aea5471d96d8e5ebe1f14b079cde9d7400afe683e5925ec87f6555c92abf9e825b2013c43c667761472b945cc1405cef89c8c10ab19f52
languageName: node
linkType: hard
"@octokit/openapi-types@npm:^20.0.0":
version: 20.0.0
resolution: "@octokit/openapi-types@npm:20.0.0"
@ -2144,6 +2258,24 @@ __metadata:
languageName: node
linkType: hard
"@octokit/openapi-webhooks-types@npm:12.0.3":
version: 12.0.3
resolution: "@octokit/openapi-webhooks-types@npm:12.0.3"
checksum: 63304f68de7484eef7aa7dfde0a9825b46f7cd99d792560c3a8e48ba740db247ae2bf8a0254ff7018f4b27ff54f29c2d5abfc47cde8aa08cbfcb5abe165a10cd
languageName: node
linkType: hard
"@octokit/plugin-paginate-rest@npm:^13.0.0":
version: 13.1.0
resolution: "@octokit/plugin-paginate-rest@npm:13.1.0"
dependencies:
"@octokit/types": ^14.1.0
peerDependencies:
"@octokit/core": ">=6"
checksum: e697757714f7c66a9e66737d34619040dd332ded58c6f78ea44917665b3b87a7a8d741465c8bc012b728de517ccf7d3b9382b08be09d7ac6b700a1d2e55b5d83
languageName: node
linkType: hard
"@octokit/plugin-paginate-rest@npm:^13.0.1":
version: 13.0.1
resolution: "@octokit/plugin-paginate-rest@npm:13.0.1"
@ -2227,6 +2359,24 @@ __metadata:
languageName: node
linkType: hard
"@octokit/webhooks-methods@npm:^6.0.0":
version: 6.0.0
resolution: "@octokit/webhooks-methods@npm:6.0.0"
checksum: 6edf536eb8a695e1c26b7530f14bfaefdeec4e92f4a43e8f27b9a10175c0904f51804801f742d4ea60a4e9b6021dc74c9ef02079a0c979b0e8be5eccabea3c45
languageName: node
linkType: hard
"@octokit/webhooks@npm:^14.0.0, @octokit/webhooks@npm:^14.0.2":
version: 14.0.2
resolution: "@octokit/webhooks@npm:14.0.2"
dependencies:
"@octokit/openapi-webhooks-types": 12.0.3
"@octokit/request-error": ^7.0.0
"@octokit/webhooks-methods": ^6.0.0
checksum: 129bb615eed93fc1c8f20fe60bb5f9548821b27fd4a5b3fc2d6e81be3fe5163f3b427181c945cefa3196a3b02d18dc642127a4bf68de5ca50e52a79039c4dde7
languageName: node
linkType: hard
"@open-draft/deferred-promise@npm:^2.2.0":
version: 2.2.0
resolution: "@open-draft/deferred-promise@npm:2.2.0"
@ -2266,7 +2416,10 @@ __metadata:
"@langchain/langgraph-cli": latest
"@langchain/langgraph-sdk": ^0.0.85
"@langchain/openai": ^0.5.10
"@octokit/app": ^16.0.1
"@octokit/core": ^7.0.2
"@octokit/rest": ^22.0.0
"@octokit/webhooks": ^14.0.2
"@open-swe/shared": "*"
"@tsconfig/recommended": ^1.0.8
"@types/jest": ^29.5.0
@ -2278,6 +2431,7 @@ __metadata:
eslint-plugin-import: ^2.27.5
eslint-plugin-no-instanceof: ^1.0.1
eslint-plugin-prettier: ^4.2.1
hono: ^4.8.3
jest: ^29.7.0
langchain: ^0.3.26
langsmith: ^0.3.29
@ -3663,6 +3817,13 @@ __metadata:
languageName: node
linkType: hard
"@types/aws-lambda@npm:^8.10.83":
version: 8.10.150
resolution: "@types/aws-lambda@npm:8.10.150"
checksum: 5bde42dfb6f566f00c55542585f81ecf6cda8063b9c27a3a49da6cab853c222987f078315000b0b9d2668a4398507c37378de66d6472727fcf3c1c6360641964
languageName: node
linkType: hard
"@types/babel__core@npm:^7.1.14":
version: 7.20.5
resolution: "@types/babel__core@npm:7.20.5"
@ -7943,6 +8104,13 @@ __metadata:
languageName: node
linkType: hard
"hono@npm:^4.8.3":
version: 4.8.3
resolution: "hono@npm:4.8.3"
checksum: cacd43002e5345547cd568c4d831f5d40b54ec8fe2f80dd03cf21d45d2112f31d08e6f57399d7ae08c0fc2329342113cdc4f0e46f3de52c971afe8ef9414c51f
languageName: node
linkType: hard
"html-escaper@npm:^2.0.0":
version: 2.0.2
resolution: "html-escaper@npm:2.0.2"
@ -13391,6 +13559,13 @@ __metadata:
languageName: node
linkType: hard
"toad-cache@npm:^3.7.0":
version: 3.7.0
resolution: "toad-cache@npm:3.7.0"
checksum: d0f2092ab2c0f3355d3537c41b13888a12996f38080e6c39907e715eb382d997ccf61baab9e8eda3f202b6c07e304728106be3631c9fe3b6c001aaf15b7bdb8f
languageName: node
linkType: hard
"toidentifier@npm:1.0.1":
version: 1.0.1
resolution: "toidentifier@npm:1.0.1"
@ -13901,6 +14076,13 @@ __metadata:
languageName: node
linkType: hard
"universal-github-app-jwt@npm:^2.2.0":
version: 2.2.2
resolution: "universal-github-app-jwt@npm:2.2.2"
checksum: 95c0801e69898aec15b42731b47e7a5a830dbe7e456ba84b66b9b78542d2e79a62d633424c98c58e6fe6b09fbad6951111cce7db85573955c14dd930ecda0ee4
languageName: node
linkType: hard
"universal-user-agent@npm:^7.0.0, universal-user-agent@npm:^7.0.2":
version: 7.0.3
resolution: "universal-user-agent@npm:7.0.3"