mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
feat(agent-generated): Add github oauth (#63)
* Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * format and lint * fix ui * Apply patch (#68) Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com> * fix: Github auth * update readme * cr * cr * cr * cr --------- Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com> Co-authored-by: Harrison Chase <hw.chase.17@gmail.com>
This commit is contained in:
parent
afbedeec0d
commit
887fc030a4
35 changed files with 2037 additions and 186 deletions
54
README.md
54
README.md
|
|
@ -45,30 +45,53 @@ GOOGLE_API_KEY=""
|
|||
|
||||
# Daytona API key for accessing and modifying the code in the cloud sandbox.
|
||||
DAYTONA_API_KEY=""
|
||||
|
||||
# Your GitHub PAT with access to the repositories you want to modify.
|
||||
GITHUB_PAT=""
|
||||
```
|
||||
|
||||
And the web `.env` file should contain the following variables:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://localhost:2024 # Change to production URL when deployed
|
||||
NEXT_PUBLIC_ASSISTANT_ID=open-swe
|
||||
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
|
||||
|
||||
# For the GitHub OAuth flow
|
||||
GITHUB_APP_CLIENT_ID=""
|
||||
GITHUB_APP_CLIENT_SECRET=""
|
||||
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
|
||||
|
||||
GITHUB_APP_NAME="open-swe-dev"
|
||||
GITHUB_APP_ID=""
|
||||
GITHUB_APP_PRIVATE_KEY=""
|
||||
```
|
||||
|
||||
To generate the GitHub personal access token, you should:
|
||||
To get the GitHub App secrets, first create a new GitHub app (note: this is not the same as the OAuth app) in [the developer settings](https://github.com/settings/apps/new).
|
||||
|
||||
1. Go to [GitHub settings](https://github.com/settings/personal-access-tokens)
|
||||
2. Click on `Generate new token` to generate a new fine grained token.
|
||||
3. Give the token a name & description.
|
||||
4. Choose `Only select repositories`, and select the repositories you want to give Open SWE access to.
|
||||
5. Under `Permissions`, give it `Repository permission`:
|
||||
- `Contents` - `Read and write`
|
||||
- `Metadata` - `Read-only` (should be auto enabled after selecting `Contents`)
|
||||
- `Pull requests` - `Read and write`
|
||||
6. Click `Generate token` & copy the token.
|
||||
7. Paste the token into the `GITHUB_PAT` variable in the agent `.env` file.
|
||||
Give the app a name and description.
|
||||
|
||||
Under `Callback URL`, set it to: `http://localhost:3000/api/auth/github/callback` for local development. Then, uncheck `Expire user authorization tokens`, and check `Request user authorization (OAuth) during installation`.
|
||||
|
||||
Under `Post installation`, check `Redirect on update`.
|
||||
|
||||
Under `Webhook`, uncheck `Active`.
|
||||
|
||||
Under `Repository permissions`, give the app the following permissions:
|
||||
|
||||
- `Contents` - `Read & Write`
|
||||
- `Metadata` - `Read & Write`
|
||||
- `Pull requests` - `Read & Write`
|
||||
- `Issues` - `Read & Write`
|
||||
|
||||
Finally, under `Where can this GitHub App be installed?` ensure `Any account` is selected.
|
||||
|
||||
After creating the app, you will be taken to the app's settings page. Copy/generate the following fields for your environment variables:
|
||||
|
||||
`App ID` - `GITHUB_APP_ID`
|
||||
`Client ID` - `GITHUB_APP_CLIENT_ID`
|
||||
`Client secrets` - Generate a new secret key, and set it under `GITHUB_APP_CLIENT_SECRET`
|
||||
Scroll down to `Private keys`, and generate a new private key. This will download a file. Set the contents of this file under `GITHUB_APP_PRIVATE_KEY`.
|
||||
Set `GITHUB_APP_REDIRECT_URI` to `http://localhost:3000/api/auth/github/callback` for local development.
|
||||
Set `GITHUB_APP_NAME` to the name of your app.
|
||||
|
||||
That's it! You can now authenticate users with GitHub, and generate tokens for them.
|
||||
|
||||
## Running the graph
|
||||
|
||||
|
|
@ -114,3 +137,4 @@ Once you've accepted the plan, it will begin the execution flow. When the agent
|
|||
## Accessing Changes
|
||||
|
||||
Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/<threadId>`. Every time a file is created, modified, or deleted, the changes will be committed to this branch. You can access the changes in the repository by checking out this branch.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,3 @@ GOOGLE_API_KEY=""
|
|||
|
||||
# Daytona API key for accessing and modifying the code in the cloud sandbox.
|
||||
DAYTONA_API_KEY=""
|
||||
|
||||
# Your GitHub PAT with access to the repositories you want to modify.
|
||||
GITHUB_PAT=""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
export const TIMEOUT_MS = 300_000; // 5 minutes
|
||||
// Always extend timeouts by 5 min after each command execution.
|
||||
export const TIMEOUT_EXTENSION_OPT = {
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
};
|
||||
export const TIMEOUT_SEC = 60; // 1 minute
|
||||
export const SANDBOX_ROOT_DIR = "/home/daytona";
|
||||
export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
applyPatchTool,
|
||||
requestHumanHelpTool,
|
||||
} from "../tools/index.js";
|
||||
import { getRepoAbsolutePath } from "../utils/git/index.js";
|
||||
import { getRepoAbsolutePath } from "../utils/git.js";
|
||||
import { formatPlanPrompt } from "../utils/plan-prompt.js";
|
||||
import { stopSandbox } from "../utils/sandbox.js";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
|
|
|
|||
|
|
@ -1,51 +1,19 @@
|
|||
import { Sandbox } from "@daytonaio/sdk";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import {
|
||||
GraphState,
|
||||
GraphConfig,
|
||||
GraphUpdate,
|
||||
TargetRepository,
|
||||
} from "../types.js";
|
||||
import { GraphState, GraphConfig, GraphUpdate } from "../types.js";
|
||||
import {
|
||||
checkoutBranch,
|
||||
cloneRepo,
|
||||
configureGitUserInRepo,
|
||||
getBranchName,
|
||||
getRepoAbsolutePath,
|
||||
pullLatestChanges,
|
||||
} from "../utils/git/index.js";
|
||||
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
|
||||
} from "../utils/git.js";
|
||||
import { daytonaClient } from "../utils/sandbox.js";
|
||||
import { SNAPSHOT_NAME } from "../constants.js";
|
||||
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "Initialize");
|
||||
|
||||
async function cloneRepo(sandbox: Sandbox, targetRepository: TargetRepository) {
|
||||
if (!process.env.GITHUB_PAT) {
|
||||
throw new Error("GITHUB_PAT environment variable not set.");
|
||||
}
|
||||
|
||||
try {
|
||||
const gitCloneCommand = ["git", "clone"];
|
||||
|
||||
const repoUrlWithToken = `https://${process.env.GITHUB_PAT}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
|
||||
|
||||
if (targetRepository.branch) {
|
||||
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
|
||||
} else {
|
||||
gitCloneCommand.push(repoUrlWithToken);
|
||||
}
|
||||
|
||||
logger.info("Cloning repository", {
|
||||
command: gitCloneCommand.join(" "),
|
||||
});
|
||||
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
|
||||
} catch (e) {
|
||||
const errorFields = getSandboxErrorFields(e);
|
||||
logger.error("Failed to clone repository", errorFields ?? e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the session. This ensures there's an active VM session, and that
|
||||
* the proper credentials are provided for taking actions on GitHub.
|
||||
|
|
@ -56,16 +24,8 @@ export async function initialize(
|
|||
state: GraphState,
|
||||
config: GraphConfig,
|
||||
): Promise<GraphUpdate> {
|
||||
if (!config.configurable) {
|
||||
throw new Error("Configuration object not found.");
|
||||
}
|
||||
const { sandboxSessionId } = state;
|
||||
const { targetRepository } = state;
|
||||
if (!targetRepository) {
|
||||
throw new Error(
|
||||
"Missing required target repository. Please provide a git repository in state or configuration.",
|
||||
);
|
||||
}
|
||||
const { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config);
|
||||
const { sandboxSessionId, targetRepository } = state;
|
||||
const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
|
||||
|
||||
if (sandboxSessionId) {
|
||||
|
|
@ -90,7 +50,7 @@ export async function initialize(
|
|||
image: SNAPSHOT_NAME,
|
||||
});
|
||||
|
||||
const res = await cloneRepo(sandbox, targetRepository);
|
||||
const res = await cloneRepo(sandbox, targetRepository, { githubToken });
|
||||
if (res.exitCode !== 0) {
|
||||
// TODO: This should probably be an interrupt.
|
||||
logger.error("Failed to clone repository", res.result);
|
||||
|
|
@ -99,7 +59,12 @@ export async function initialize(
|
|||
logger.info("Repository cloned successfully.");
|
||||
|
||||
logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`);
|
||||
await configureGitUserInRepo(absoluteRepoDir, sandbox);
|
||||
await configureGitUserInRepo(absoluteRepoDir, sandbox, {
|
||||
githubToken,
|
||||
githubAccessToken,
|
||||
owner: targetRepository.owner,
|
||||
repo: targetRepository.repo,
|
||||
});
|
||||
logger.info("Git user configured successfully.");
|
||||
|
||||
const checkoutBranchRes = await checkoutBranch(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getBranchName,
|
||||
getChangedFilesStatus,
|
||||
getRepoAbsolutePath,
|
||||
} from "../utils/git/index.js";
|
||||
} from "../utils/git.js";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import { z } from "zod";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
|
|
@ -13,6 +13,7 @@ import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js";
|
|||
import { isHumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import { getMessageContentString } from "../utils/message/content.js";
|
||||
import { daytonaClient } from "../utils/sandbox.js";
|
||||
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "Open PR");
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ export async function openPullRequest(
|
|||
"Failed to open pull request: No sandbox session ID found in state.",
|
||||
);
|
||||
}
|
||||
const { githubToken } = getGitHubTokensFromConfig(config);
|
||||
|
||||
const sandbox = await daytonaClient().get(sandboxSessionId);
|
||||
|
||||
|
|
@ -133,6 +135,7 @@ export async function openPullRequest(
|
|||
headBranch: branchName ?? getBranchName(config),
|
||||
title,
|
||||
body,
|
||||
githubToken,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
checkoutBranchAndCommit,
|
||||
getChangedFilesStatus,
|
||||
getRepoAbsolutePath,
|
||||
} from "../utils/git/index.js";
|
||||
} from "../utils/git.js";
|
||||
import {
|
||||
formatBadArgsError,
|
||||
zodSchemaToString,
|
||||
|
|
|
|||
|
|
@ -361,6 +361,28 @@ export const GraphConfiguration = z.object({
|
|||
"The maximum number of tokens to generate in an individual generation",
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The user's GitHub installation token. To be used to take actions on behalf of the user.
|
||||
*/
|
||||
"x-github-installation-token": z
|
||||
.string()
|
||||
.optional()
|
||||
.langgraph.metadata({
|
||||
x_oap_ui_config: {
|
||||
type: "hidden",
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The user's GitHub access token. To be used in requests to get information about the user.
|
||||
*/
|
||||
"x-github-access-token": z
|
||||
.string()
|
||||
.optional()
|
||||
.langgraph.metadata({
|
||||
x_oap_ui_config: {
|
||||
type: "hidden",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type GraphConfig = LangGraphRunnableConfig<
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Octokit } from "@octokit/rest";
|
||||
import { Sandbox } from "@daytonaio/sdk";
|
||||
import { createLogger, LogLevel } from "../logger.js";
|
||||
import { GraphConfig, TargetRepository } from "../../types.js";
|
||||
import { TIMEOUT_MS, SANDBOX_ROOT_DIR } from "../../constants.js";
|
||||
import { getSandboxErrorFields } from "../sandbox-error-fields.js";
|
||||
import { createLogger, LogLevel } from "./logger.js";
|
||||
import { GraphConfig, TargetRepository } from "../types.js";
|
||||
import { TIMEOUT_SEC, SANDBOX_ROOT_DIR } from "../constants.js";
|
||||
import { getSandboxErrorFields } from "./sandbox-error-fields.js";
|
||||
import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "GitUtil");
|
||||
|
|
@ -40,7 +40,7 @@ export async function checkoutBranch(
|
|||
"git branch --show-current",
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (getCurrentBranchOutput.exitCode !== 0) {
|
||||
|
|
@ -80,7 +80,7 @@ export async function checkoutBranch(
|
|||
`git rev-parse --verify --quiet "refs/heads/${branchName}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (checkBranchExistsOutput.exitCode === 0) {
|
||||
|
|
@ -116,7 +116,7 @@ export async function checkoutBranch(
|
|||
checkoutCommand,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (gitCheckoutOutput.exitCode !== 0) {
|
||||
|
|
@ -152,29 +152,24 @@ interface GitHubUserResponse {
|
|||
email: string | null;
|
||||
}
|
||||
|
||||
async function getGitUserDetailsFromGitHub(): Promise<{
|
||||
async function getGitUserDetailsFromGitHub(githubToken: string): Promise<{
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
}> {
|
||||
const githubToken = process.env.GITHUB_PAT;
|
||||
if (!githubToken) {
|
||||
logger.warn(
|
||||
"GITHUB_PAT environment variable is not set. Cannot fetch user details from GitHub.",
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// Try with Bearer token first (for GitHub App installation tokens)
|
||||
const response = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `token ${githubToken}`,
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenSWE",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch GitHub user info`, {
|
||||
response,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
|
@ -198,6 +193,10 @@ async function getGitUserDetailsFromGitHub(): Promise<{
|
|||
if (!finalUserEmail) {
|
||||
logger.warn("Could not determine GitHub user email from API response.");
|
||||
}
|
||||
logger.info("Successfully fetched GitHub user info", {
|
||||
userName: finalUserName,
|
||||
userEmail: finalUserEmail,
|
||||
});
|
||||
return { userName: finalUserName, userEmail: finalUserEmail };
|
||||
} catch (e) {
|
||||
logger.error(`Error fetching GitHub user info`, {
|
||||
|
|
@ -214,20 +213,27 @@ async function getGitUserDetailsFromGitHub(): Promise<{
|
|||
export async function configureGitUserInRepo(
|
||||
absoluteRepoDir: string,
|
||||
sandbox: Sandbox,
|
||||
args: {
|
||||
githubToken: string;
|
||||
githubAccessToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { githubToken, githubAccessToken, owner, repo } = args;
|
||||
let needsGitConfig = false;
|
||||
try {
|
||||
const nameCheck = await sandbox.process.executeCommand(
|
||||
"git config user.name",
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
const emailCheck = await sandbox.process.executeCommand(
|
||||
"git config user.email",
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (
|
||||
|
|
@ -249,39 +255,70 @@ export async function configureGitUserInRepo(
|
|||
needsGitConfig = true;
|
||||
}
|
||||
|
||||
if (needsGitConfig) {
|
||||
const { userName, userEmail } = await getGitUserDetailsFromGitHub();
|
||||
// Configure git to use the token for authentication with GitHub by updating the remote URL
|
||||
logger.info(
|
||||
"Configuring git to use token for GitHub authentication via remote URL...",
|
||||
);
|
||||
try {
|
||||
// Set the remote URL with the token using the provided owner and repo
|
||||
const setRemoteOutput = await sandbox.process.executeCommand(
|
||||
`git remote set-url origin https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (userName) {
|
||||
const configUserNameOutput = await sandbox.process.executeCommand(
|
||||
`git config user.name "${userName}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
if (configUserNameOutput.exitCode !== 0) {
|
||||
logger.error(`Failed to set git user.name`, {
|
||||
configUserNameOutput,
|
||||
});
|
||||
} else {
|
||||
logger.info(`Set git user.name to '${userName}' successfully.`);
|
||||
}
|
||||
if (setRemoteOutput.exitCode !== 0) {
|
||||
logger.error(`Failed to set remote URL with token`, {
|
||||
setRemoteOutput,
|
||||
});
|
||||
} else {
|
||||
logger.info("Git remote URL updated with token successfully.");
|
||||
}
|
||||
} catch (authError) {
|
||||
logger.error(`Error configuring git authentication for GitHub`, {
|
||||
...(authError instanceof Error && {
|
||||
name: authError.name,
|
||||
message: authError.message,
|
||||
stack: authError.stack,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (needsGitConfig) {
|
||||
const { userName, userEmail } =
|
||||
await getGitUserDetailsFromGitHub(githubAccessToken);
|
||||
|
||||
// Set user name - use fetched name or fallback to "GitHub App User"
|
||||
const nameToUse = userName || "GitHub App User";
|
||||
const configUserNameOutput = await sandbox.process.executeCommand(
|
||||
`git config user.name "${nameToUse}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
if (configUserNameOutput.exitCode !== 0) {
|
||||
logger.error(`Failed to set git user.name`, {
|
||||
configUserNameOutput,
|
||||
});
|
||||
} else {
|
||||
logger.info(`Set git user.name to '${nameToUse}' successfully.`);
|
||||
}
|
||||
|
||||
if (userEmail) {
|
||||
const configUserEmailOutput = await sandbox.process.executeCommand(
|
||||
`git config user.email "${userEmail}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
if (configUserEmailOutput.exitCode !== 0) {
|
||||
logger.error(`Failed to set git user.email`, {
|
||||
configUserEmailOutput,
|
||||
});
|
||||
} else {
|
||||
logger.info(`Set git user.email to '${userEmail}' successfully.`);
|
||||
}
|
||||
// Set user email - use fetched email or fallback to a generic noreply address
|
||||
const emailToUse = userEmail || "open-swe-bot@noreply.github.com";
|
||||
const configUserEmailOutput = await sandbox.process.executeCommand(
|
||||
`git config user.email "${emailToUse}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
if (configUserEmailOutput.exitCode !== 0) {
|
||||
logger.error(`Failed to set git user.email`, {
|
||||
configUserEmailOutput,
|
||||
});
|
||||
} else {
|
||||
logger.info(`Set git user.email to '${emailToUse}' successfully.`);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
|
|
@ -300,7 +337,7 @@ export async function commitAll(
|
|||
`git add -A && git commit -m "${message}"`,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (gitAddOutput.exitCode !== 0) {
|
||||
|
|
@ -330,7 +367,9 @@ export async function commitAllAndPush(
|
|||
): Promise<ExecuteResponse | false> {
|
||||
try {
|
||||
const commitOutput = await commitAll(absoluteRepoDir, message, sandbox);
|
||||
|
||||
logger.info(
|
||||
"Committed changes to git repository successfully. Now pushing...",
|
||||
);
|
||||
const pushCurrentBranchCmd =
|
||||
"git push -u origin $(git rev-parse --abbrev-ref HEAD)";
|
||||
|
||||
|
|
@ -342,7 +381,7 @@ export async function commitAllAndPush(
|
|||
pushCurrentBranchCmd,
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (gitPushOutput.exitCode !== 0) {
|
||||
|
|
@ -375,7 +414,7 @@ export async function getChangedFilesStatus(
|
|||
"git status --porcelain",
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
|
||||
if (gitStatusOutput.exitCode !== 0) {
|
||||
|
|
@ -418,21 +457,17 @@ export async function createPullRequest({
|
|||
headBranch,
|
||||
title,
|
||||
body = "",
|
||||
githubToken,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
headBranch: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
githubToken: string;
|
||||
}) {
|
||||
// Initialize Octokit with the personal access token
|
||||
const token = process.env.GITHUB_PAT;
|
||||
if (!token) {
|
||||
throw new Error("GITHUB_PAT environment variable is not set");
|
||||
}
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: token,
|
||||
auth: githubToken,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -476,7 +511,7 @@ export async function pullLatestChanges(
|
|||
"git pull",
|
||||
absoluteRepoDir,
|
||||
undefined,
|
||||
TIMEOUT_MS,
|
||||
TIMEOUT_SEC,
|
||||
);
|
||||
return gitPullOutput;
|
||||
} catch (e) {
|
||||
|
|
@ -492,3 +527,35 @@ export async function pullLatestChanges(
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneRepo(
|
||||
sandbox: Sandbox,
|
||||
targetRepository: TargetRepository,
|
||||
args: {
|
||||
githubToken: string;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const gitCloneCommand = ["git", "clone"];
|
||||
|
||||
// Use x-access-token format for better GitHub authentication
|
||||
const repoUrlWithToken = `https://x-access-token:${args.githubToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
|
||||
|
||||
if (targetRepository.branch) {
|
||||
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
|
||||
} else {
|
||||
gitCloneCommand.push(repoUrlWithToken);
|
||||
}
|
||||
|
||||
logger.info("Cloning repository", {
|
||||
// Don't log the full command with token for security reasons
|
||||
repoPath: `${targetRepository.owner}/${targetRepository.repo}`,
|
||||
branch: targetRepository.branch || "default",
|
||||
});
|
||||
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
|
||||
} catch (e) {
|
||||
const errorFields = getSandboxErrorFields(e);
|
||||
logger.error("Failed to clone repository", errorFields ?? e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
21
apps/open-swe/src/utils/github-tokens.ts
Normal file
21
apps/open-swe/src/utils/github-tokens.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { GraphConfig } from "../types.js";
|
||||
|
||||
export function getGitHubTokensFromConfig(config: GraphConfig): {
|
||||
githubToken: string;
|
||||
githubAccessToken: string;
|
||||
} {
|
||||
if (!config.configurable) {
|
||||
throw new Error("No configurable object found in graph config.");
|
||||
}
|
||||
const githubToken = config.configurable["x-github-installation-token"];
|
||||
const githubAccessToken = config.configurable["x-github-access-token"];
|
||||
if (!githubToken) {
|
||||
throw new Error(
|
||||
"Missing required x-github-installation-token in configuration.",
|
||||
);
|
||||
}
|
||||
if (!githubAccessToken) {
|
||||
throw new Error("Missing required x-github-access-token in configuration.");
|
||||
}
|
||||
return { githubToken, githubAccessToken };
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
# LangGraph Configuration
|
||||
NEXT_PUBLIC_API_URL=http://localhost:2024
|
||||
NEXT_PUBLIC_ASSISTANT_ID=agent
|
||||
# Do NOT prefix this with "NEXT_PUBLIC_" as we do not want this exposed in the client.
|
||||
LANGSMITH_API_KEY=
|
||||
NEXT_PUBLIC_API_URL="http://localhost:2024"
|
||||
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
|
||||
|
||||
# Production LangGraph Configuration (quickstart) - Uncomment to use
|
||||
# NEXT_PUBLIC_ASSISTANT_ID="agent"
|
||||
# This should be the deployment URL of your LangGraph server
|
||||
# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
|
||||
# This should be the URL of your website + "/api". This is how you connect to the API proxy
|
||||
# NEXT_PUBLIC_API_URL="https://my-website.com/api"
|
||||
# LANGSMITH_API_KEY="lsv2_..."
|
||||
# For the GitHub OAuth flow
|
||||
GITHUB_APP_CLIENT_ID=""
|
||||
GITHUB_APP_CLIENT_SECRET=""
|
||||
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
|
||||
|
||||
GITHUB_APP_NAME="Open SWE Dev"
|
||||
GITHUB_APP_ID=""
|
||||
GITHUB_APP_PRIVATE_KEY=""
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-tailwindcss": "^2.0.1",
|
||||
"framer-motion": "^12.4.9",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.21",
|
||||
"langgraph-nextjs-api-passthrough": "^0.0.4",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -62,6 +63,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@tailwindcss/postcss": "^4.0.13",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.8",
|
||||
|
|
|
|||
133
apps/web/src/app/api/auth/github/callback/route.ts
Normal file
133
apps/web/src/app/api/auth/github/callback/route.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import {
|
||||
GITHUB_AUTH_STATE_COOKIE,
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
GITHUB_TOKEN_TYPE_COOKIE,
|
||||
} from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const error = searchParams.get("error");
|
||||
const installationId = searchParams.get("installation_id");
|
||||
|
||||
// Handle GitHub App errors
|
||||
if (error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/?error=${encodeURIComponent(error)}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=missing_code_parameter", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify state parameter to prevent CSRF attacks
|
||||
const storedState = request.cookies.get(GITHUB_AUTH_STATE_COOKIE)?.value;
|
||||
|
||||
if (storedState && state !== storedState) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=invalid_state", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = process.env.GITHUB_APP_CLIENT_ID;
|
||||
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
|
||||
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=configuration_missing", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange authorization code for access token
|
||||
const tokenResponse = await fetch(
|
||||
"https://github.com/login/oauth/access_token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error("Token exchange failed:", await tokenResponse.text());
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=token_exchange_failed", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
if (tokenData.error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/?error=${encodeURIComponent(tokenData.error)}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Create the success response
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/?auth=success", request.url),
|
||||
);
|
||||
|
||||
// Clear the state cookie as it's no longer needed
|
||||
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, "", {
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Set token cookies directly on the response
|
||||
response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, {
|
||||
// httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
|
||||
response.cookies.set(
|
||||
GITHUB_TOKEN_TYPE_COOKIE,
|
||||
tokenData.token_type || "bearer",
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
},
|
||||
);
|
||||
|
||||
// If there's an installation_id, store that as well for future API calls
|
||||
if (installationId) {
|
||||
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("GitHub App callback error:", error);
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=callback_failed", request.url),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
apps/web/src/app/api/auth/github/login/route.ts
Normal file
45
apps/web/src/app/api/auth/github/login/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { GITHUB_AUTH_STATE_COOKIE } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const clientId = process.env.GITHUB_APP_CLIENT_ID;
|
||||
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return NextResponse.json(
|
||||
{ error: "GitHub App configuration missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a random state parameter for security
|
||||
const state = crypto.randomUUID();
|
||||
|
||||
// Build the GitHub App authorization URL
|
||||
const authUrl = new URL("https://github.com/login/oauth/authorize");
|
||||
authUrl.searchParams.set("client_id", clientId);
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("allow_signup", "true");
|
||||
authUrl.searchParams.set("state", state);
|
||||
|
||||
// Create response with redirect and store state in a cookie
|
||||
const response = NextResponse.redirect(authUrl.toString());
|
||||
|
||||
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("GitHub App login error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to initiate GitHub App authentication flow" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
19
apps/web/src/app/api/auth/logout/route.ts
Normal file
19
apps/web/src/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clearGitHubToken } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* API route to handle GitHub logout
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const response = NextResponse.json({ success: true });
|
||||
clearGitHubToken(response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Failed to logout" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
18
apps/web/src/app/api/auth/status/route.ts
Normal file
18
apps/web/src/app/api/auth/status/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* API route to check GitHub authentication status
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authenticated = isAuthenticated(request);
|
||||
return NextResponse.json({ authenticated });
|
||||
} catch (error) {
|
||||
console.error("Error checking auth status:", error);
|
||||
return NextResponse.json(
|
||||
{ authenticated: false, error: "Failed to check authentication status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
73
apps/web/src/app/api/github/installation-callback/route.ts
Normal file
73
apps/web/src/app/api/github/installation-callback/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
|
||||
GITHUB_INSTALLATION_STATE_COOKIE,
|
||||
} from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Handles callbacks from GitHub App installations
|
||||
* This endpoint is called by GitHub after a user installs or configures the GitHub App
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const installationId = searchParams.get("installation_id");
|
||||
const setupAction = searchParams.get("setup_action");
|
||||
|
||||
// Get the return URL from cookies
|
||||
const returnTo =
|
||||
request.cookies.get(GITHUB_INSTALLATION_RETURN_TO_COOKIE)?.value || "/";
|
||||
|
||||
// Verify state parameter to prevent CSRF attacks
|
||||
// GitHub App installation doesn't return the state directly, but we included it in our callback URL
|
||||
const customState = searchParams.get("custom_state");
|
||||
const storedState = request.cookies.get(
|
||||
GITHUB_INSTALLATION_STATE_COOKIE,
|
||||
)?.value;
|
||||
|
||||
// Validate state if it exists
|
||||
if (storedState && customState && storedState !== customState) {
|
||||
console.warn("Invalid installation state detected");
|
||||
// We'll still proceed but log the warning
|
||||
}
|
||||
|
||||
// Create the response that will redirect back to the app
|
||||
const response = NextResponse.redirect(returnTo);
|
||||
|
||||
// Clear cookies as they're no longer needed
|
||||
const expiredCookieOptions = {
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
};
|
||||
|
||||
response.cookies.set(
|
||||
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
|
||||
"",
|
||||
expiredCookieOptions,
|
||||
);
|
||||
response.cookies.set(
|
||||
GITHUB_INSTALLATION_STATE_COOKIE,
|
||||
"",
|
||||
expiredCookieOptions,
|
||||
);
|
||||
|
||||
// If we have an installation ID, store it in a cookie
|
||||
if (installationId) {
|
||||
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("GitHub App installation callback error:", error);
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=installation_callback_failed", request.url),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
apps/web/src/app/api/github/installation/callback/route.ts
Normal file
40
apps/web/src/app/api/github/installation/callback/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Handles the callback from GitHub App installation
|
||||
* After a user installs the app and selects repositories, GitHub redirects here
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const installationId = searchParams.get("installation_id");
|
||||
const setupAction = searchParams.get("setup_action");
|
||||
|
||||
if (!installationId) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=missing_installation_id", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Store the installation ID in a cookie for future API calls
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/?installation=success", request.url),
|
||||
);
|
||||
|
||||
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("GitHub App installation callback error:", error);
|
||||
return NextResponse.redirect(
|
||||
new URL("/?error=installation_callback_failed", request.url),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
apps/web/src/app/api/github/installation/route.ts
Normal file
84
apps/web/src/app/api/github/installation/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
|
||||
GITHUB_INSTALLATION_STATE_COOKIE,
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
} from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* Initiates the GitHub App installation flow
|
||||
* This redirects users to the GitHub App installation page where they can
|
||||
* select which repositories to grant access to
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "GitHub access token not found" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get GitHub App name from environment variables
|
||||
const githubAppName = process.env.GITHUB_APP_NAME;
|
||||
|
||||
if (!githubAppName) {
|
||||
return NextResponse.json(
|
||||
{ error: "GitHub App name not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing state or generate a new one
|
||||
let state = request.cookies.get(GITHUB_INSTALLATION_STATE_COOKIE)?.value;
|
||||
|
||||
// If no state exists or we want to ensure a fresh state, generate a new one
|
||||
if (!state) {
|
||||
state = randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
// Create a response that will redirect to the GitHub App installation page
|
||||
// Include the callback URL as a parameter to ensure GitHub redirects back to our app
|
||||
// Add the state as a custom parameter in the callback URL
|
||||
const baseCallbackUrl = `${request.nextUrl.origin}/api/github/installation-callback`;
|
||||
const callbackUrl = `${baseCallbackUrl}?custom_state=${encodeURIComponent(state)}`;
|
||||
const response = NextResponse.redirect(
|
||||
`https://github.com/apps/${githubAppName}/installations/new?redirect_uri=${encodeURIComponent(callbackUrl)}`,
|
||||
);
|
||||
|
||||
// Cookie options for security and proper expiration
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
path: "/",
|
||||
};
|
||||
|
||||
// Store the state in a cookie for validation when GitHub redirects back
|
||||
response.cookies.set(
|
||||
GITHUB_INSTALLATION_STATE_COOKIE,
|
||||
state,
|
||||
cookieOptions,
|
||||
);
|
||||
|
||||
// Store the current URL as the return_to URL so we can redirect back after installation
|
||||
const returnTo = request.headers.get("referer") || "/";
|
||||
response.cookies.set(
|
||||
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
|
||||
returnTo,
|
||||
cookieOptions,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error initiating GitHub App installation:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to initiate GitHub App installation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
92
apps/web/src/app/api/github/repositories/route.ts
Normal file
92
apps/web/src/app/api/github/repositories/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getInstallationToken,
|
||||
getInstallationRepositories,
|
||||
Repository,
|
||||
} from "@/utils/github";
|
||||
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Fetches repositories accessible to the GitHub App installation
|
||||
* Requires a valid GitHub installation ID in the cookies
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the installation ID from cookies
|
||||
const installationId = request.cookies.get(
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
)?.value;
|
||||
|
||||
if (!installationId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"GitHub installation ID not found. Please install the app first.",
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get GitHub App credentials from environment variables
|
||||
const appId = process.env.GITHUB_APP_ID;
|
||||
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
|
||||
/\\n/g,
|
||||
"\n",
|
||||
);
|
||||
|
||||
if (!appId || !privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "GitHub App configuration missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get an installation access token
|
||||
let installationToken: string;
|
||||
try {
|
||||
installationToken = await getInstallationToken(
|
||||
installationId,
|
||||
appId,
|
||||
privateKey,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get installation token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to get installation token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch repositories accessible to this installation
|
||||
let repositories: Repository[];
|
||||
try {
|
||||
repositories = await getInstallationRepositories(installationToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch repositories:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch repositories" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Transform the response to include only the data we need
|
||||
const transformedRepos = repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
description: repo.description,
|
||||
private: repo.private,
|
||||
html_url: repo.html_url,
|
||||
default_branch: repo.default_branch,
|
||||
permissions: repo.permissions,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ repositories: transformedRepos });
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub repositories:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch repositories" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
66
apps/web/src/app/api/github/token/route.ts
Normal file
66
apps/web/src/app/api/github/token/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getInstallationToken } from "@/utils/github";
|
||||
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Returns a GitHub installation token that can be used for Git operations
|
||||
* This endpoint is intended for internal use by the AI coding agent
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the installation ID from cookies
|
||||
const installationId = request.cookies.get(
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
)?.value;
|
||||
|
||||
if (!installationId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"GitHub installation ID not found. Please install the app first.",
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get GitHub App credentials from environment variables
|
||||
const appId = process.env.GITHUB_APP_ID;
|
||||
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
|
||||
/\\n/g,
|
||||
"\n",
|
||||
);
|
||||
|
||||
if (!appId || !privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "GitHub App configuration missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get an installation access token
|
||||
try {
|
||||
const installationToken = await getInstallationToken(
|
||||
installationId,
|
||||
appId,
|
||||
privateKey,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
token: installationToken,
|
||||
installation_id: installationId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get installation token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to get installation token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating GitHub token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
156
apps/web/src/app/github/page.tsx
Normal file
156
apps/web/src/app/github/page.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Repository } from "@/utils/github";
|
||||
|
||||
export default function GitHubPage() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkInstallation = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/github/repositories");
|
||||
if (response.ok) {
|
||||
setIsInstalled(true);
|
||||
const data = await response.json();
|
||||
setRepositories(data.repositories || []);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error.includes("installation")) {
|
||||
setIsInstalled(false);
|
||||
} else {
|
||||
setError(errorData.error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to check installation status");
|
||||
}
|
||||
};
|
||||
|
||||
checkInstallation();
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
window.location.href = "/api/github/installation";
|
||||
};
|
||||
|
||||
const handleRefreshRepositories = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/github/repositories");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRepositories(data.repositories || []);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch repositories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">GitHub Repository Access</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isInstalled ? (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
|
||||
<h2 className="mb-4 text-xl font-semibold">Install GitHub App</h2>
|
||||
<p className="mb-4">
|
||||
To access your GitHub repositories, you need to install our GitHub
|
||||
App and grant it access to the repositories you want to use.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="rounded bg-black px-4 py-2 text-white hover:bg-gray-800"
|
||||
>
|
||||
Install GitHub App
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Your Repositories</h2>
|
||||
<button
|
||||
onClick={handleRefreshRepositories}
|
||||
disabled={loading}
|
||||
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{repositories.length === 0 ? (
|
||||
<p>
|
||||
No repositories found. Make sure you've granted access to at least
|
||||
one repository.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{repositories.map((repo) => (
|
||||
<li
|
||||
key={repo.id}
|
||||
className="py-4"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
<a
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{repo.full_name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{repo.description || "No description"}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-sm">
|
||||
<span className="mr-4">
|
||||
{repo.private ? "Private" : "Public"}
|
||||
</span>
|
||||
<span>Default branch: {repo.default_branch}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-white p-6 shadow-md">
|
||||
<h2 className="mb-4 text-xl font-semibold">Manage GitHub App</h2>
|
||||
<p className="mb-4">
|
||||
You can manage your GitHub App installation, including adding or
|
||||
removing repositories, through GitHub.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
|
||||
>
|
||||
Manage on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/github-oauth-button.tsx
Normal file
93
apps/web/src/components/github-oauth-button.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitHubSVG } from "@/components/icons/github";
|
||||
|
||||
export function GitHubOAuthButton() {
|
||||
const [isAuth, setIsAuth] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
setIsAuth(data.authenticated);
|
||||
} catch (error) {
|
||||
console.error("Error checking auth status:", error);
|
||||
setIsAuth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoading(true);
|
||||
window.location.href = "/api/auth/github/login";
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsAuth(false);
|
||||
} else {
|
||||
console.error("Logout failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuth === null) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
Checking...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuth) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{isLoading ? "Disconnecting..." : "Disconnect GitHub"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{isLoading ? "Connecting..." : "Connect GitHub"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
151
apps/web/src/components/github/AgentTokenProvider.tsx
Normal file
151
apps/web/src/components/github/AgentTokenProvider.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyIcon, CheckIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { InstallAppButton } from "./InstallAppButton";
|
||||
|
||||
interface AgentTokenProviderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to fetch and display a GitHub installation token for use with the AI agent
|
||||
* This token can be passed to your agent service to perform Git operations on behalf of the user
|
||||
*/
|
||||
export function AgentTokenProvider({
|
||||
className = "",
|
||||
}: AgentTokenProviderProps) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
|
||||
const fetchToken = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/github/token");
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || "Failed to fetch token");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setToken(data.token);
|
||||
setInstallationId(data.installation_id);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError("Network error when fetching token");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, []);
|
||||
|
||||
const copyToken = () => {
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (error && error.includes("installation")) {
|
||||
return (
|
||||
<div className={`rounded-md border p-4 ${className}`}>
|
||||
<h3 className="mb-2 text-lg font-medium">GitHub App Not Installed</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
You need to install our GitHub App to generate tokens for the AI
|
||||
agent.
|
||||
</p>
|
||||
<InstallAppButton>Install GitHub App</InstallAppButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`rounded-md border p-4 ${className}`}>
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchToken}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border p-4 ${className}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">GitHub Token for AI Agent</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchToken}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCwIcon className="mr-2 h-4 w-4" />
|
||||
Refresh Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 w-3/4 rounded bg-gray-200"></div>
|
||||
<div className="h-10 rounded bg-gray-200"></div>
|
||||
</div>
|
||||
) : token ? (
|
||||
<>
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
This token expires in 1 hour. Use it to authenticate your AI agent
|
||||
with GitHub.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<div className="mb-2 overflow-x-auto rounded-md border bg-gray-50 p-3 font-mono text-sm whitespace-nowrap">
|
||||
{token}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={copyToken}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">How to use this token:</p>
|
||||
<div className="rounded-md border bg-gray-50 p-3 font-mono text-xs">
|
||||
{`export GITHUB_TOKEN=${token}`}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Pass this token to your agent service to perform Git operations on
|
||||
behalf of the user.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-600">Loading token...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/components/github/InstallAppButton.tsx
Normal file
54
apps/web/src/components/github/InstallAppButton.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitHubSVG } from "@/components/icons/github";
|
||||
|
||||
interface InstallAppButtonProps {
|
||||
variant?:
|
||||
| "default"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link"
|
||||
| "destructive";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InstallAppButton({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
children,
|
||||
}: InstallAppButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleInstall = () => {
|
||||
setIsLoading(true);
|
||||
window.location.href = "/api/github/installation";
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleInstall}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
"Installing..."
|
||||
) : (
|
||||
<>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{children || "Install GitHub App"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
148
apps/web/src/components/github/RepositoryList.tsx
Normal file
148
apps/web/src/components/github/RepositoryList.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useGitHubApp } from "@/hooks/useGitHubApp";
|
||||
import { InstallAppButton } from "./InstallAppButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface RepositoryListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RepositoryList({ className = "" }: RepositoryListProps) {
|
||||
const { isInstalled, isLoading, error, repositories, refreshRepositories } =
|
||||
useGitHubApp();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="mb-4 h-6 w-24 animate-pulse rounded bg-gray-200"></div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mb-4 rounded-md border p-4"
|
||||
>
|
||||
<div className="mb-2 h-5 w-48 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-4 w-64 animate-pulse rounded bg-gray-100"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshRepositories}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInstalled) {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||
<h3 className="mb-2 font-medium">GitHub App Not Installed</h3>
|
||||
<p className="mb-4 text-sm text-amber-800">
|
||||
You need to install our GitHub App to grant access to your
|
||||
repositories.
|
||||
</p>
|
||||
<InstallAppButton>Install GitHub App</InstallAppButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Your GitHub Repositories</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshRepositories}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{repositories.length === 0 ? (
|
||||
<div className="rounded-md border bg-gray-50 p-4 text-center">
|
||||
<p className="mb-4 text-gray-600">
|
||||
No repositories found. You may need to grant access to repositories
|
||||
in GitHub.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Manage GitHub App permissions
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repositories.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="rounded-md border p-4 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
<a
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{repo.full_name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{repo.description || "No description"}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-xs">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${repo.private ? "bg-gray-100" : "bg-green-100 text-green-800"} mr-2`}
|
||||
>
|
||||
{repo.private ? "Private" : "Public"}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Default branch: {repo.default_branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t pt-4 text-sm text-gray-500">
|
||||
<p>
|
||||
Need to add or remove repositories?{" "}
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Manage on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
|
|||
width={width}
|
||||
height={height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
SquarePen,
|
||||
XIcon,
|
||||
Plus,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
|
@ -31,13 +30,6 @@ import { toast } from "sonner";
|
|||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { GitHubSVG } from "../icons/github";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { ContentBlocksPreview } from "./ContentBlocksPreview";
|
||||
import {
|
||||
|
|
@ -46,6 +38,7 @@ import {
|
|||
ArtifactTitle,
|
||||
useArtifactContext,
|
||||
} from "./artifact";
|
||||
import { GitHubOAuthButton } from "../github-oauth-button";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
|
|
@ -88,30 +81,6 @@ function ScrollToBottom(props: { className?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OpenGitHubRepo() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/langchain-ai/open-swe"
|
||||
target="_blank"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<GitHubSVG
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Open GitHub repo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thread() {
|
||||
const [artifactContext, setArtifactContext] = useArtifactContext();
|
||||
const [artifactOpen, closeArtifact] = useArtifactOpen();
|
||||
|
|
@ -337,7 +306,7 @@ export function Thread() {
|
|||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-4 flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
<GitHubOAuthButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -383,7 +352,7 @@ export function Thread() {
|
|||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
<GitHubOAuthButton />
|
||||
</div>
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
61
apps/web/src/hooks/useGitHubApp.ts
Normal file
61
apps/web/src/hooks/useGitHubApp.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Repository } from "@/utils/github";
|
||||
|
||||
interface UseGitHubAppReturn {
|
||||
isInstalled: boolean | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
repositories: Repository[];
|
||||
refreshRepositories: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useGitHubApp(): UseGitHubAppReturn {
|
||||
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
|
||||
const checkInstallation = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/github/repositories");
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRepositories(data.repositories || []);
|
||||
setIsInstalled(true);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error.includes("installation")) {
|
||||
setIsInstalled(false);
|
||||
} else {
|
||||
setError(errorData.error);
|
||||
setIsInstalled(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to check GitHub App installation status");
|
||||
setIsInstalled(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkInstallation();
|
||||
}, []);
|
||||
|
||||
const refreshRepositories = async () => {
|
||||
await checkInstallation();
|
||||
};
|
||||
|
||||
return {
|
||||
isInstalled,
|
||||
isLoading,
|
||||
error,
|
||||
repositories,
|
||||
refreshRepositories,
|
||||
};
|
||||
}
|
||||
59
apps/web/src/hooks/useGitHubToken.ts
Normal file
59
apps/web/src/hooks/useGitHubToken.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState, useCallback } from "react";
|
||||
|
||||
interface TokenResponse {
|
||||
token: string;
|
||||
installation_id: string;
|
||||
}
|
||||
|
||||
interface UseGitHubTokenReturn {
|
||||
token: string | null;
|
||||
installationId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a GitHub installation token that can be used for Git operations
|
||||
* This token can be passed to the agent service to perform Git operations on behalf of the user
|
||||
*/
|
||||
export function useGitHubToken(): UseGitHubTokenReturn {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchToken = useCallback(async (): Promise<string | null> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/github/token");
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || "Failed to fetch token");
|
||||
setIsLoading(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: TokenResponse = await response.json();
|
||||
setToken(data.token);
|
||||
setInstallationId(data.installation_id);
|
||||
setIsLoading(false);
|
||||
return data.token;
|
||||
} catch (err) {
|
||||
setError("Network error when fetching token");
|
||||
setIsLoading(false);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
token,
|
||||
installationId,
|
||||
isLoading,
|
||||
error,
|
||||
fetchToken,
|
||||
};
|
||||
}
|
||||
114
apps/web/src/lib/auth.ts
Normal file
114
apps/web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Prefix the access token with `x-` so that it's included in requests to the LangGraph server.
|
||||
export const GITHUB_TOKEN_COOKIE = "x-github_access_token";
|
||||
export const GITHUB_TOKEN_TYPE_COOKIE = "github_token_type";
|
||||
export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id";
|
||||
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";
|
||||
export const GITHUB_INSTALLATION_STATE_COOKIE = "github_installation_state";
|
||||
export const GITHUB_INSTALLATION_RETURN_TO_COOKIE = "installation_return_to";
|
||||
|
||||
export interface GitHubTokenData {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
installation_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie options for GitHub token cookies
|
||||
*/
|
||||
function getCookieOptions(expires?: Date) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
maxAge: expires ? undefined : 60 * 60 * 24 * 30, // 30 days
|
||||
expires,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores GitHub OAuth token data in secure HTTP-only cookies
|
||||
*
|
||||
* @param tokenData The GitHub token data to store
|
||||
* @param response NextResponse to set cookies on
|
||||
*/
|
||||
export function storeGitHubToken(
|
||||
tokenData: GitHubTokenData,
|
||||
response: NextResponse,
|
||||
): void {
|
||||
const cookieOptions = getCookieOptions();
|
||||
|
||||
// Store token components in separate cookies for better security
|
||||
response.cookies.set(
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
tokenData.access_token,
|
||||
cookieOptions,
|
||||
);
|
||||
response.cookies.set(
|
||||
GITHUB_TOKEN_TYPE_COOKIE,
|
||||
tokenData.token_type || "bearer",
|
||||
cookieOptions,
|
||||
);
|
||||
|
||||
// Store installation ID if provided
|
||||
if (tokenData.installation_id) {
|
||||
response.cookies.set(
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
tokenData.installation_id,
|
||||
cookieOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves GitHub OAuth token data from cookies
|
||||
*
|
||||
* @param request NextRequest to get cookies from
|
||||
*/
|
||||
export function getGitHubToken(request: NextRequest): GitHubTokenData | null {
|
||||
try {
|
||||
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
|
||||
const tokenType = request.cookies.get(GITHUB_TOKEN_TYPE_COOKIE)?.value;
|
||||
const installationId = request.cookies.get(
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
)?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType || "bearer",
|
||||
installation_id: installationId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error retrieving GitHub token:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes GitHub OAuth token data from cookies (logout)
|
||||
*
|
||||
* @param response NextResponse to set cookies on
|
||||
*/
|
||||
export function clearGitHubToken(response: NextResponse): void {
|
||||
const cookieOptions = getCookieOptions(new Date(0)); // Expire immediately
|
||||
|
||||
response.cookies.set(GITHUB_TOKEN_COOKIE, "", cookieOptions);
|
||||
response.cookies.set(GITHUB_TOKEN_TYPE_COOKIE, "", cookieOptions);
|
||||
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, "", cookieOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user has a valid GitHub token
|
||||
*
|
||||
* @param request NextRequest to get cookies from
|
||||
*/
|
||||
export function isAuthenticated(request: NextRequest): boolean {
|
||||
const token = getGitHubToken(request);
|
||||
return token !== null && token.access_token.length > 0;
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { createContext, useContext, ReactNode, useState } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { type Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
|
|
@ -12,9 +18,13 @@ import { useQueryState } from "nuqs";
|
|||
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
|
||||
import { useThreads } from "./Thread";
|
||||
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
|
||||
import { Copy, CopyCheck } from "lucide-react";
|
||||
import { Copy, CopyCheck, ArrowRight } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitHubSVG } from "@/components/icons/github";
|
||||
import { useGitHubToken } from "@/hooks/useGitHubToken";
|
||||
|
||||
type TargetRepository = { owner: string; repo: string };
|
||||
export type StateType = {
|
||||
messages: Message[];
|
||||
|
|
@ -46,17 +56,28 @@ const StreamSession = ({
|
|||
children,
|
||||
apiUrl,
|
||||
assistantId,
|
||||
githubToken,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
apiUrl: string;
|
||||
assistantId: string;
|
||||
githubToken: string;
|
||||
}) => {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const { getThreads, setThreads } = useThreads();
|
||||
const githubAccessToken =
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("x-github_access_token="))
|
||||
?.split("=")[1] || "";
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl,
|
||||
assistantId,
|
||||
threadId: threadId ?? null,
|
||||
defaultHeaders: {
|
||||
"x-github-installation-token": githubToken,
|
||||
"x-github-access-token": githubAccessToken,
|
||||
},
|
||||
onCustomEvent: (event, options) => {
|
||||
if (isUIMessage(event) || isRemoveUIMessage(event)) {
|
||||
options.mutate((prev) => {
|
||||
|
|
@ -90,6 +111,71 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
|||
const assistantId: string | undefined =
|
||||
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
|
||||
|
||||
const [isAuth, setIsAuth] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isCheckingAppInstallation, setIsCheckingAppInstallation] =
|
||||
useState(false);
|
||||
const { token: githubToken, fetchToken: fetchGitHubToken } = useGitHubToken();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuth) {
|
||||
checkGitHubAppInstallation();
|
||||
}
|
||||
}, [isAuth]);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
setIsAuth(data.authenticated);
|
||||
} catch (error) {
|
||||
console.error("Error checking auth status:", error);
|
||||
setIsAuth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkGitHubAppInstallation = async () => {
|
||||
setIsCheckingAppInstallation(true);
|
||||
try {
|
||||
const response = await fetch("/api/github/repositories");
|
||||
if (response.ok) {
|
||||
setHasGitHubAppInstalled(true);
|
||||
// If the app is installed, fetch a token
|
||||
await fetchGitHubToken();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error.includes("installation")) {
|
||||
setHasGitHubAppInstalled(false);
|
||||
} else {
|
||||
// If there's a different error, we'll assume the app is not installed
|
||||
setHasGitHubAppInstalled(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking GitHub App installation:", error);
|
||||
setHasGitHubAppInstalled(false);
|
||||
} finally {
|
||||
setIsCheckingAppInstallation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoading(true);
|
||||
window.location.href = "/api/auth/github/login";
|
||||
};
|
||||
|
||||
const handleInstallGitHubApp = () => {
|
||||
setIsLoading(true);
|
||||
window.location.href = "/api/github/installation";
|
||||
};
|
||||
|
||||
if (!apiUrl || !assistantId) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
|
|
@ -154,10 +240,91 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (!isAuth) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
|
||||
<div className="flex flex-col gap-4 border-b p-6">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<LangGraphLogoSVG className="h-7" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Get started
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your GitHub account to get started with Open SWE.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{isLoading ? "Connecting..." : "Connect GitHub"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: GitHub App Installation (only show if authenticated but app not installed)
|
||||
if ((isAuth && hasGitHubAppInstalled === false) || !githubToken) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
|
||||
<div className="flex flex-col gap-4 border-b p-6">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<LangGraphLogoSVG className="h-7" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
One more step
|
||||
</h1>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
1. GitHub Login ✓
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
2. Repository Access
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Great! Now we need access to your GitHub repositories. Install our
|
||||
GitHub App to grant access to specific repositories.
|
||||
</p>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<p>
|
||||
You'll be redirected to GitHub where you can select which
|
||||
repositories to grant access to.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleInstallGitHubApp}
|
||||
disabled={isLoading || isCheckingAppInstallation}
|
||||
className="bg-black hover:bg-gray-800"
|
||||
>
|
||||
<GitHubSVG
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{isLoading || isCheckingAppInstallation
|
||||
? "Loading..."
|
||||
: "Install GitHub App"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamSession
|
||||
apiUrl={apiUrl}
|
||||
assistantId={assistantId}
|
||||
githubToken={githubToken}
|
||||
>
|
||||
{children}
|
||||
</StreamSession>
|
||||
|
|
|
|||
97
apps/web/src/utils/github.ts
Normal file
97
apps/web/src/utils/github.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import * as jwt from "jsonwebtoken";
|
||||
|
||||
/**
|
||||
* Generates a JWT for GitHub App authentication
|
||||
*/
|
||||
export function generateJWT(appId: string, privateKey: string): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload = {
|
||||
iat: now,
|
||||
exp: now + 10 * 60,
|
||||
iss: appId,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, privateKey, { algorithm: "RS256" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an installation access token for a GitHub App installation
|
||||
*/
|
||||
export async function getInstallationToken(
|
||||
installationId: string,
|
||||
appId: string,
|
||||
privateKey: string,
|
||||
): Promise<string> {
|
||||
const jwtToken = generateJWT(appId, privateKey);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/app/installations/${installationId}/access_tokens`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "YourAppName",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
`Failed to get installation token: ${JSON.stringify(errorData)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches repositories accessible to a GitHub App installation
|
||||
*/
|
||||
export async function getInstallationRepositories(
|
||||
installationToken: string,
|
||||
): Promise<Repository[]> {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/installation/repositories",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${installationToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "YourAppName",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
`Failed to fetch repositories: ${JSON.stringify(errorData)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.repositories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface representing GitHub repository data
|
||||
*/
|
||||
export interface Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
private: boolean;
|
||||
html_url: string;
|
||||
default_branch: string;
|
||||
permissions: {
|
||||
admin: boolean;
|
||||
maintain: boolean;
|
||||
push: boolean;
|
||||
triage: boolean;
|
||||
pull: boolean;
|
||||
};
|
||||
}
|
||||
118
yarn.lock
118
yarn.lock
|
|
@ -1958,6 +1958,7 @@ __metadata:
|
|||
"@radix-ui/react-switch": ^1.1.3
|
||||
"@radix-ui/react-tooltip": ^1.1.8
|
||||
"@tailwindcss/postcss": ^4.0.13
|
||||
"@types/jsonwebtoken": ^9.0.9
|
||||
"@types/lodash": ^4.17.16
|
||||
"@types/node": ^22.13.5
|
||||
"@types/react": ^19.0.8
|
||||
|
|
@ -1978,6 +1979,7 @@ __metadata:
|
|||
eslint-plugin-react-refresh: ^0.4.18
|
||||
framer-motion: ^12.4.9
|
||||
globals: ^15.14.0
|
||||
jsonwebtoken: ^9.0.2
|
||||
katex: ^0.16.21
|
||||
langgraph-nextjs-api-passthrough: ^0.0.4
|
||||
lodash: ^4.17.21
|
||||
|
|
@ -3288,6 +3290,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/jsonwebtoken@npm:^9.0.9":
|
||||
version: 9.0.9
|
||||
resolution: "@types/jsonwebtoken@npm:9.0.9"
|
||||
dependencies:
|
||||
"@types/ms": "*"
|
||||
"@types/node": "*"
|
||||
checksum: 9d564fc09fc83f66e319754dee0b039ae23f28fc38a9227c35283e69311ddf5a7493a060c7f956df8cd856416747c515b51fec20b8656865eacebf23b3c6bc62
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/katex@npm:^0.16.0":
|
||||
version: 0.16.7
|
||||
resolution: "@types/katex@npm:0.16.7"
|
||||
|
|
@ -4364,6 +4376,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"buffer-equal-constant-time@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "buffer-equal-constant-time@npm:1.0.1"
|
||||
checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"buffer-from@npm:^1.0.0":
|
||||
version: 1.1.2
|
||||
resolution: "buffer-from@npm:1.1.2"
|
||||
|
|
@ -5218,6 +5237,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ecdsa-sig-formatter@npm:1.0.11":
|
||||
version: 1.0.11
|
||||
resolution: "ecdsa-sig-formatter@npm:1.0.11"
|
||||
dependencies:
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eciesjs@npm:^0.4.10":
|
||||
version: 0.4.15
|
||||
resolution: "eciesjs@npm:0.4.15"
|
||||
|
|
@ -8181,6 +8209,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonwebtoken@npm:^9.0.2":
|
||||
version: 9.0.2
|
||||
resolution: "jsonwebtoken@npm:9.0.2"
|
||||
dependencies:
|
||||
jws: ^3.2.2
|
||||
lodash.includes: ^4.3.0
|
||||
lodash.isboolean: ^3.0.3
|
||||
lodash.isinteger: ^4.0.4
|
||||
lodash.isnumber: ^3.0.3
|
||||
lodash.isplainobject: ^4.0.6
|
||||
lodash.isstring: ^4.0.1
|
||||
lodash.once: ^4.0.0
|
||||
ms: ^2.1.1
|
||||
semver: ^7.5.4
|
||||
checksum: fc739a6a8b33f1974f9772dca7f8493ca8df4cc31c5a09dcfdb7cff77447dcf22f4236fb2774ef3fe50df0abeb8e1c6f4c41eba82f500a804ab101e2fbc9d61a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5":
|
||||
version: 3.3.5
|
||||
resolution: "jsx-ast-utils@npm:3.3.5"
|
||||
|
|
@ -8193,6 +8239,27 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jwa@npm:^1.4.1":
|
||||
version: 1.4.2
|
||||
resolution: "jwa@npm:1.4.2"
|
||||
dependencies:
|
||||
buffer-equal-constant-time: ^1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: fd1a6de6c649a4b16f0775439ac9173e4bc9aa0162c7f3836699af47736ae000fafe89f232a2345170de6c14021029cb94b488f7882c6caf61e6afef5fce6494
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jws@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "jws@npm:3.2.2"
|
||||
dependencies:
|
||||
jwa: ^1.4.1
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"katex@npm:^0.16.0, katex@npm:^0.16.21":
|
||||
version: 0.16.22
|
||||
resolution: "katex@npm:0.16.22"
|
||||
|
|
@ -8531,6 +8598,48 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.includes@npm:^4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "lodash.includes@npm:4.3.0"
|
||||
checksum: 71092c130515a67ab3bd928f57f6018434797c94def7f46aafa417771e455ce3a4834889f4267b17887d7f75297dfabd96231bf704fd2b8c5096dc4a913568b6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isboolean@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "lodash.isboolean@npm:3.0.3"
|
||||
checksum: b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isinteger@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "lodash.isinteger@npm:4.0.4"
|
||||
checksum: 6034821b3fc61a2ffc34e7d5644bb50c5fd8f1c0121c554c21ac271911ee0c0502274852845005f8651d51e199ee2e0cfebfe40aaa49c7fe617f603a8a0b1691
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isnumber@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "lodash.isnumber@npm:3.0.3"
|
||||
checksum: 913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isplainobject@npm:^4.0.6":
|
||||
version: 4.0.6
|
||||
resolution: "lodash.isplainobject@npm:4.0.6"
|
||||
checksum: 29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isstring@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "lodash.isstring@npm:4.0.1"
|
||||
checksum: eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.memoize@npm:^4.1.2":
|
||||
version: 4.1.2
|
||||
resolution: "lodash.memoize@npm:4.1.2"
|
||||
|
|
@ -8545,6 +8654,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.once@npm:^4.0.0":
|
||||
version: 4.1.1
|
||||
resolution: "lodash.once@npm:4.1.1"
|
||||
checksum: d768fa9f9b4e1dc6453be99b753906f58990e0c45e7b2ca5a3b40a33111e5d17f6edf2f768786e2716af90a8e78f8f91431ab8435f761fef00f9b0c256f6d245
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash@npm:^4.17.21":
|
||||
version: 4.17.21
|
||||
resolution: "lodash@npm:4.17.21"
|
||||
|
|
@ -10917,7 +11033,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-buffer@npm:~5.2.0":
|
||||
"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
|
||||
version: 5.2.1
|
||||
resolution: "safe-buffer@npm:5.2.1"
|
||||
checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue