feat: Add GitHub installation selector dropdown for multi-org support (#365)

* Apply patch x10

* fix build

* unifying GH org installations [org select, repo select, /github]

* github multi installation management

* clean git diff

* CR: remove HTTP constraint from GH install cookie, rm api endpoint, update hooks

* CR: use octokit types for GH install

* CR: GH loading component, fix org selector icons

* minor github loading fix

* rm un-needed gh install api route

* CR: InstallationCookieOptions

---------

Co-authored-by: open-swe-dev[bot] <open-swe-dev@users.noreply.github.com>
Co-authored-by: starmorph <dylan@starmorph.com>
Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com>
This commit is contained in:
open-swe-dev[bot] 2025-07-10 15:45:26 -07:00 • committed by GitHub
parent c8344beb88
commit ba38da81c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 803 additions and 329 deletions

View file

@ -72,6 +72,7 @@
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@octokit/types": "^14.1.0",
"@tailwindcss/postcss": "^4.0.13",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.17.16",

View file

@ -1,7 +1,7 @@
"use client";
import type React from "react";
import { useState } from "react";
import { useState, Suspense } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@ -12,11 +12,13 @@ import { useThreadsSWR } from "@/hooks/useThreadsSWR";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { ThreadCard, ThreadCardLoading } from "@/components/v2/thread-card";
import { ThemeToggle } from "@/components/theme-toggle";
import { InstallationSelector } from "@/components/github/installation-selector";
import { GitHubAppProvider } from "@/providers/GitHubApp";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
type FilterStatus = "all" | "running" | "completed" | "failed" | "pending";
export default function AllThreadsPage() {
function AllThreadsPageContent() {
const router = useRouter();
const { threads, isLoading: threadsLoading } = useThreadsSWR<GraphState>({
assistantId: MANAGER_GRAPH_ID,
@ -55,157 +57,174 @@ export default function AllThreadsPage() {
};
return (
<div className="bg-background flex h-screen flex-col">
{/* Header */}
<div className="border-border bg-card border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-muted hover:text-foreground h-6 w-6 p-0"
onClick={() => router.push("/chat")}
>
<ArrowLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-muted-foreground font-mono text-sm">
All Threads
</span>
</div>
<div className="ml-auto flex items-center gap-4">
<GitHubAppProvider>
<div className="bg-background flex h-screen flex-col">
{/* Header */}
<div className="border-border bg-card border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-muted hover:text-foreground h-6 w-6 p-0"
onClick={() => router.push("/chat")}
>
<ArrowLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{filteredThreads.length} threads
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-muted-foreground font-mono text-sm">
All Threads
</span>
</div>
<ThemeToggle />
</div>
</div>
</div>
{/* Search and Filters */}
<div className="border-border bg-muted/50 border-b px-4 py-3 dark:bg-gray-950">
<div className="flex items-center gap-3">
<div className="relative max-w-md flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-border bg-background text-foreground placeholder:text-muted-foreground pl-10 dark:bg-gray-900"
/>
</div>
<div className="flex items-center gap-1">
<Filter className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground mr-2 text-xs">Filter:</span>
{(
[
"all",
"running",
"completed",
"failed",
"pending",
] as FilterStatus[]
).map((status) => (
<Button
key={status}
variant={statusFilter === status ? "secondary" : "ghost"}
size="sm"
className={`h-7 text-xs ${
statusFilter === status
? "bg-muted text-foreground dark:bg-gray-700"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
onClick={() => setStatusFilter(status)}
>
{status === "all"
? "All"
: status.charAt(0).toUpperCase() + status.slice(1)}
<Badge
variant="secondary"
className="bg-muted/70 text-muted-foreground ml-1 text-xs dark:bg-gray-800"
>
{statusCounts[status]}
</Badge>
</Button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl p-4">
{statusFilter === "all" ? (
// Show grouped view when "all" is selected
<div className="space-y-6">
{Object.entries(groupedThreads).map(([status, threads]) => {
if (threads.length === 0) return null;
return (
<div key={status}>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-foreground text-base font-semibold capitalize">
{status} Threads
</h2>
<Badge
variant="secondary"
className="bg-muted/70 text-muted-foreground text-xs dark:bg-gray-800"
>
{threads.length}
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{threads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
/>
))}
</div>
</div>
);
})}
<div className="ml-auto flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{filteredThreads.length} threads
</span>
</div>
<div className="flex items-center gap-2">
<InstallationSelector />
<ThemeToggle />
</div>
</div>
) : (
// Show flat list when specific status is selected
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
/>
</div>
</div>
{/* Search and Filters */}
<div className="border-border bg-muted/50 border-b px-4 py-3 dark:bg-gray-950">
<div className="flex items-center gap-3">
<div className="relative max-w-md flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-border bg-background text-foreground placeholder:text-muted-foreground pl-10 dark:bg-gray-900"
/>
</div>
<div className="flex items-center gap-1">
<Filter className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground mr-2 text-xs">
Filter:
</span>
{(
[
"all",
"running",
"completed",
"failed",
"pending",
] as FilterStatus[]
).map((status) => (
<Button
key={status}
variant={statusFilter === status ? "secondary" : "ghost"}
size="sm"
className={`h-7 text-xs ${
statusFilter === status
? "bg-muted text-foreground dark:bg-gray-700"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
onClick={() => setStatusFilter(status)}
>
{status === "all"
? "All"
: status.charAt(0).toUpperCase() + status.slice(1)}
<Badge
variant="secondary"
className="bg-muted/70 text-muted-foreground ml-1 text-xs dark:bg-gray-800"
>
{statusCounts[status]}
</Badge>
</Button>
))}
</div>
)}
</div>
</div>
{filteredThreads.length === 0 && !threadsLoading && (
<div className="py-12 text-center">
<div className="text-muted-foreground mb-2">No threads found</div>
<div className="text-muted-foreground/70 text-xs">
{searchQuery
? "Try adjusting your search query"
: "No threads match the selected filter"}
</div>
</div>
)}
{threadsLoading && threads.length === 0 && (
<div>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-foreground text-base font-semibold capitalize">
Loading threads...
</h2>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl p-4">
{statusFilter === "all" ? (
// Show grouped view when "all" is selected
<div className="space-y-6">
{Object.entries(groupedThreads).map(([status, threads]) => {
if (threads.length === 0) return null;
return (
<div key={status}>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-foreground text-base font-semibold capitalize">
{status} Threads
</h2>
<Badge
variant="secondary"
className="bg-muted/70 text-muted-foreground text-xs dark:bg-gray-800"
>
{threads.length}
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{threads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
/>
))}
</div>
</div>
);
})}
</div>
) : (
// Show flat list when specific status is selected
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, index) => (
<ThreadCardLoading key={`all-threads-loading-${index}`} />
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
/>
))}
</div>
</div>
)}
)}
{filteredThreads.length === 0 && !threadsLoading && (
<div className="py-12 text-center">
<div className="text-muted-foreground mb-2">
No threads found
</div>
<div className="text-muted-foreground/70 text-xs">
{searchQuery
? "Try adjusting your search query"
: "No threads match the selected filter"}
</div>
</div>
)}
{threadsLoading && threads.length === 0 && (
<div>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-foreground text-base font-semibold capitalize">
Loading threads...
</h2>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, index) => (
<ThreadCardLoading key={`all-threads-loading-${index}`} />
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</GitHubAppProvider>
);
}
export default function AllThreadsPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AllThreadsPageContent />
</Suspense>
);
}

View file

@ -4,6 +4,7 @@ import {
GITHUB_TOKEN_TYPE_COOKIE,
GITHUB_TOKEN_COOKIE,
} from "@open-swe/shared/constants";
import { getInstallationCookieOptions } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
@ -112,13 +113,11 @@ export async function GET(request: NextRequest) {
// 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: "/",
});
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
installationId,
getInstallationCookieOptions(),
);
}
return response;

View file

@ -2,6 +2,7 @@ import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
import {
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
GITHUB_INSTALLATION_STATE_COOKIE,
getInstallationCookieOptions,
} from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
@ -53,13 +54,11 @@ export async function GET(request: NextRequest) {
// 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: "/",
});
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
installationId,
getInstallationCookieOptions(),
);
}
return response;

View file

@ -1,39 +0,0 @@
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
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");
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),
);
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { getGitHubToken } from "@/lib/auth";
import { Endpoints } from "@octokit/types";
type GitHubInstallationsResponse =
Endpoints["GET /user/installations"]["response"]["data"];
/**
* Fetches all GitHub App installations accessible to the current user
* Uses the user's access token from GITHUB_TOKEN_COOKIE to call GET /user/installations
*/
export async function GET(request: NextRequest) {
try {
// Get the user's access token from cookies
const tokenData = getGitHubToken(request);
if (!tokenData || !tokenData.access_token) {
return NextResponse.json(
{
error: "GitHub access token not found. Please authenticate first.",
},
{ status: 401 },
);
}
// Fetch installations from GitHub API
const response = await fetch("https://api.github.com/user/installations", {
headers: {
Authorization: `${tokenData.token_type} ${tokenData.access_token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json(
{
error: `Failed to fetch installations: ${JSON.stringify(errorData)}`,
},
{ status: response.status },
);
}
const data: GitHubInstallationsResponse = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error fetching GitHub installations:", error);
return NextResponse.json(
{ error: "Failed to fetch installations" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
import { getInstallationCookieOptions } from "@/lib/auth";
/**
* Updates the current GitHub installation ID in the cookie
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { installationId } = body;
if (!installationId || typeof installationId !== "string") {
return NextResponse.json(
{ error: "Installation ID is required" },
{ status: 400 },
);
}
// Create response and set the new installation ID cookie
const response = NextResponse.json({ success: true });
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
installationId,
getInstallationCookieOptions(),
);
return response;
} catch (error) {
console.error("Error switching installation ID:", error);
return NextResponse.json(
{ error: "Failed to switch installation ID" },
{ status: 500 },
);
}
}

View file

@ -1,65 +1,29 @@
"use client";
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
import { Suspense } from "react";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { GitHubAppProvider } from "@/providers/GitHubApp";
import { InstallationSelector } from "@/components/github/installation-selector";
import { GithubPageLoading } from "@/components/github/github-page-loading";
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 {
setError("Failed to check installation status");
}
};
checkInstallation();
}, []);
function GitHubPageContent() {
const { repositories, isLoading, error, isInstalled, refreshRepositories } =
useGitHubApp();
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 {
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>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">GitHub Repository Access</h1>
{isInstalled && (
<div className="flex items-center gap-4">
<InstallationSelector />
</div>
)}
</div>
{error && (
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
@ -82,25 +46,29 @@ export default function GitHubPage() {
</button>
</div>
) : (
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
<div className="mb-6 rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
<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"
onClick={refreshRepositories}
disabled={isLoading}
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300 disabled:opacity-50 dark:bg-gray-700 dark:hover:bg-gray-600"
>
{loading ? "Refreshing..." : "Refresh"}
{isLoading ? "Refreshing..." : "Refresh"}
</button>
</div>
{repositories.length === 0 ? (
<p>
{repositories.length === 0 && !isLoading ? (
<p className="text-gray-600 dark:text-gray-400">
No repositories found. Make sure you've granted access to at least
one repository.
one repository for the selected organization.
</p>
) : isLoading ? (
<p className="text-gray-600 dark:text-gray-400">
Loading repositories...
</p>
) : (
<ul className="divide-y">
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{repositories.map((repo) => (
<li
key={repo.id}
@ -113,15 +81,15 @@ export default function GitHubPage() {
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
className="text-blue-600 hover:underline dark:text-blue-400"
>
{repo.full_name}
</a>
</h3>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{repo.description || "No description"}
</p>
<div className="mt-2 flex items-center text-sm">
<div className="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400">
<span className="mr-4">
{repo.private ? "Private" : "Public"}
</span>
@ -136,9 +104,9 @@ export default function GitHubPage() {
</div>
)}
<div className="rounded-lg bg-white p-6 shadow-md">
<div className="rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Manage GitHub App</h2>
<p className="mb-4">
<p className="mb-4 text-gray-600 dark:text-gray-400">
You can manage your GitHub App installation, including adding or
removing repositories, through GitHub.
</p>
@ -146,7 +114,7 @@ export default function GitHubPage() {
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"
className="inline-block rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Manage on GitHub
</a>
@ -154,3 +122,13 @@ export default function GitHubPage() {
</div>
);
}
export default function GitHubPage() {
return (
<Suspense fallback={<GithubPageLoading />}>
<GitHubAppProvider>
<GitHubPageContent />
</GitHubAppProvider>
</Suspense>
);
}

View file

@ -5,49 +5,40 @@ import { GitHubSVG } from "@/components/icons/github";
import { ArrowRight } from "lucide-react";
import { LangGraphLogoSVG } from "../icons/langgraph";
import { useGitHubToken } from "@/hooks/useGitHubToken";
import { useGitHubAppProvider } from "@/providers/GitHubApp";
import { GitHubAppProvider } from "@/providers/GitHubApp";
export default function AuthStatus() {
function AuthStatusContent() {
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
boolean | null
>(() => {
if (typeof window === "undefined") return null;
const cached = localStorage.getItem("github_app_installed");
return cached === "true" ? true : cached === "false" ? false : null;
});
const [isCheckingAppInstallation, setIsCheckingAppInstallation] =
useState(false);
const {
token: githubToken,
fetchToken: fetchGitHubToken,
isLoading: isTokenLoading,
} = useGitHubToken();
const {
isInstalled: hasGitHubAppInstalled,
isLoading: isCheckingAppInstallation,
} = useGitHubAppProvider();
useEffect(() => {
checkAuthStatus();
}, []);
useEffect(() => {
if (isAuth) {
const cachedInstallationStatus = localStorage.getItem(
"github_app_installed",
);
if (cachedInstallationStatus === "true") {
setHasGitHubAppInstalled(true);
// Fetch token if we don't have one yet
if (!githubToken && !isTokenLoading) {
fetchGitHubToken();
}
} else if (cachedInstallationStatus === "false") {
setHasGitHubAppInstalled(false);
} else {
checkGitHubAppInstallation();
}
if (isAuth && hasGitHubAppInstalled && !githubToken && !isTokenLoading) {
// Fetch token when app is installed but we don't have a token yet
fetchGitHubToken();
}
}, [isAuth, githubToken]);
}, [
isAuth,
hasGitHubAppInstalled,
githubToken,
isTokenLoading,
fetchGitHubToken,
]);
const checkAuthStatus = async () => {
try {
@ -60,34 +51,6 @@ export default function AuthStatus() {
}
};
const checkGitHubAppInstallation = async () => {
setIsCheckingAppInstallation(true);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
setHasGitHubAppInstalled(true);
localStorage.setItem("github_app_installed", "true");
await fetchGitHubToken();
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
} else {
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
}
}
} catch (error) {
console.error("Error checking GitHub App installation:", error);
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
} finally {
setIsCheckingAppInstallation(false);
}
};
const handleLogin = () => {
setIsLoading(true);
window.location.href = "/api/auth/github/login";
@ -95,8 +58,6 @@ export default function AuthStatus() {
const handleInstallGitHubApp = () => {
setIsLoading(true);
localStorage.removeItem("github_app_installed");
window.location.href = "/api/github/installation";
};
@ -199,3 +160,11 @@ export default function AuthStatus() {
);
}
}
export default function AuthStatus() {
return (
<GitHubAppProvider>
<AuthStatusContent />
</GitHubAppProvider>
);
}

View file

@ -4,6 +4,8 @@ import { useState } from "react";
import { LogOut } from "lucide-react";
import { TooltipIconButton } from "../ui/tooltip-icon-button";
const GITHUB_APP_INSTALLED_KEY = "github_app_installed";
export function GitHubLogoutButton() {
const [isLoading, setIsLoading] = useState(false);
@ -14,7 +16,7 @@ export function GitHubLogoutButton() {
method: "POST",
});
if (response.ok) {
localStorage.removeItem("github_app_installed");
localStorage.removeItem(GITHUB_APP_INSTALLED_KEY);
window.location.href = "/";
} else {
console.error("Logout failed");

View file

@ -0,0 +1,30 @@
import React from "react";
export function GithubPageLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">GitHub Repository Access</h1>
<div className="h-8 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="mb-6 rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Your Repositories</h2>
<div className="h-10 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="py-4"
>
<div className="mb-2 h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="mb-2 h-3 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-1/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -5,6 +5,8 @@ import { InstallationPrompt } from "./installation-prompt";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
const GITHUB_INSTALLATION_SEEN_KEY = "github_installation_seen";
export function GitHubInstallationBanner() {
const { isInstalled, isLoading } = useGitHubAppProvider();
const [dismissed, setDismissed] = useState(false);
@ -13,11 +15,11 @@ export function GitHubInstallationBanner() {
useEffect(() => {
// Check if this might be a new user (no installation history in localStorage)
const hasSeenInstallation = localStorage.getItem(
"github_installation_seen",
GITHUB_INSTALLATION_SEEN_KEY,
);
if (!hasSeenInstallation && !isInstalled && !isLoading) {
setIsNewUser(true);
localStorage.setItem("github_installation_seen", "true");
localStorage.setItem(GITHUB_INSTALLATION_SEEN_KEY, "true");
}
}, [isInstalled, isLoading]);

View file

@ -0,0 +1,142 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
import { useGitHubAppProvider } from "@/providers/GitHubApp";
import { cn } from "@/lib/utils";
import { Building2, User } from "lucide-react";
import type { Installation } from "@/hooks/useGitHubInstallations";
interface InstallationSelectorProps {
disabled?: boolean;
placeholder?: string;
className?: string;
size?: "sm" | "default";
}
export function InstallationSelector({
disabled = false,
placeholder = "Select organization/user...",
className,
size = "sm",
}: InstallationSelectorProps) {
const {
installations,
currentInstallation,
installationsLoading: isLoading,
installationsError: error,
switchInstallation,
} = useGitHubAppProvider();
const handleValueChange = async (value: string) => {
await switchInstallation(value);
};
const getAccountIcon = (accountType: "User" | "Organization") => {
return accountType === "Organization" ? (
<Building2 className="h-4 w-4" />
) : (
<User className="h-4 w-4" />
);
};
if (isLoading) {
return (
<Button
variant="outline"
disabled
size={size}
className={cn("min-w-[200px]", className)}
>
<div className="flex items-center gap-2">
<GitHubSVG />
<span>Loading...</span>
</div>
</Button>
);
}
if (error) {
return (
<Button
variant="outline"
disabled
size={size}
className={cn("text-destructive min-w-[200px]", className)}
>
<div className="flex items-center gap-2">
<GitHubSVG />
<span>Error loading installations</span>
</div>
</Button>
);
}
if (installations.length === 0) {
return (
<Button
variant="outline"
disabled
size={size}
className={cn("text-muted-foreground min-w-[200px]", className)}
>
<div className="flex items-center gap-2">
<GitHubSVG />
<span>No installations found</span>
</div>
</Button>
);
}
return (
<Select
value={currentInstallation?.id.toString() || ""}
onValueChange={handleValueChange}
disabled={disabled}
>
<SelectTrigger
size={size}
className={cn("min-w-[200px]", className)}
>
<div className="flex items-center gap-2">
{currentInstallation ? (
<div className="flex items-center gap-2">
<img
src={currentInstallation.avatarUrl}
alt={`${currentInstallation.accountName} avatar`}
className="h-4 w-4 rounded-full"
/>
<span className="truncate">
{currentInstallation.accountName}
</span>
</div>
) : (
<SelectValue placeholder={placeholder} />
)}
</div>
</SelectTrigger>
<SelectContent>
{installations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id.toString()}
>
<div className="flex items-center gap-2">
<img
src={installation.avatarUrl}
alt={`${installation.accountName} avatar`}
className="h-4 w-4 rounded-full"
/>
<span>{installation.accountName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -24,6 +24,7 @@ import { DraftsSection } from "./drafts-section";
import { GitHubLogoutButton } from "../github/github-oauth-button";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
import { TooltipIconButton } from "../ui/tooltip-icon-button";
import { InstallationSelector } from "../github/installation-selector";
interface DefaultViewProps {
threads: ThreadDisplayInfo[];
@ -71,6 +72,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
<span className="text-muted-foreground text-xs">ready</span>
<div className="bg-muted h-1 w-1 rounded-full"></div>
</div>
<InstallationSelector />
<ThemeToggle />
<GitHubLogoutButton />
</div>

View file

@ -3,15 +3,19 @@ import { useQueryState } from "nuqs";
import { Repository, getRepositoryBranches, Branch } from "@/utils/github";
import { getRepository } from "@/utils/github";
import type { TargetRepository } from "@open-swe/shared/open-swe/types";
import {
useGitHubInstallations,
type Installation,
} from "@/hooks/useGitHubInstallations";
const SELECTED_REPO_STORAGE_KEY = "selected-repository";
const GITHUB_SELECTED_REPO_KEY = "selected-repository";
const saveRepositoryToLocalStorage = (repo: TargetRepository | null) => {
try {
if (repo) {
localStorage.setItem(SELECTED_REPO_STORAGE_KEY, JSON.stringify(repo));
localStorage.setItem(GITHUB_SELECTED_REPO_KEY, JSON.stringify(repo));
} else {
localStorage.removeItem(SELECTED_REPO_STORAGE_KEY);
localStorage.removeItem(GITHUB_SELECTED_REPO_KEY);
}
} catch (error) {
console.warn("Failed to save repository to localStorage:", error);
@ -20,7 +24,7 @@ const saveRepositoryToLocalStorage = (repo: TargetRepository | null) => {
const getRepositoryFromLocalStorage = (): TargetRepository | null => {
try {
const stored = localStorage.getItem(SELECTED_REPO_STORAGE_KEY);
const stored = localStorage.getItem(GITHUB_SELECTED_REPO_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
@ -48,6 +52,14 @@ interface UseGitHubAppReturn {
isLoading: boolean;
error: string | null;
// Installation management
installations: Installation[];
currentInstallation: Installation | null;
installationsLoading: boolean;
installationsError: string | null;
switchInstallation: (installationId: string) => Promise<void>;
refreshInstallations: () => Promise<void>;
// Repository state and pagination
repositories: Repository[];
repositoriesPage: number;
@ -82,6 +94,17 @@ interface UseGitHubAppReturn {
}
export function useGitHubApp(): UseGitHubAppReturn {
// Use the centralized installation state
const {
currentInstallationId,
installations,
currentInstallation,
isLoading: installationsLoading,
error: installationsError,
switchInstallation,
refreshInstallations,
} = useGitHubInstallations();
// Installation and general state
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -268,9 +291,23 @@ export function useGitHubApp(): UseGitHubAppReturn {
}
}, [branchesHasMore, branchesLoadingMore, branchesPage, fetchBranches]);
// Refresh repositories when installation changes
useEffect(() => {
checkInstallation();
}, []);
if (currentInstallationId) {
// Clear selected repository and branches when installation changes
setSelectedRepository(null);
setBranches([]);
setRepositoriesPage(1);
setRepositoriesHasMore(false);
// Reset auto-selection flags so they can run again for the new installation
hasAutoSelectedRef.current = false;
hasCheckedLocalStorageRef.current = false;
// Fetch repositories for the new installation
checkInstallation();
}
}, [currentInstallationId]);
useEffect(() => {
if (
@ -397,6 +434,14 @@ export function useGitHubApp(): UseGitHubAppReturn {
isLoading,
error,
// Installation management
installations,
currentInstallation,
installationsLoading,
installationsError,
switchInstallation,
refreshInstallations,
// Repository state and pagination
repositories,
repositoriesPage,

View file

@ -0,0 +1,203 @@
import { useState, useEffect, useCallback } from "react";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
import { getCookie } from "@/lib/utils";
import { Endpoints } from "@octokit/types";
type GitHubInstallationsResponse =
Endpoints["GET /user/installations"]["response"]["data"];
type GitHubInstallation = GitHubInstallationsResponse["installations"][0];
export interface Installation {
id: number;
accountName: string;
accountType: "User" | "Organization";
avatarUrl: string;
}
interface UseGitHubInstallationsReturn {
// Installation data
installations: Installation[];
currentInstallationId: string | null;
currentInstallation: Installation | null;
// State management
isLoading: boolean;
error: string | null;
// Actions
refreshInstallations: () => Promise<void>;
refreshCurrentInstallation: () => void;
switchInstallation: (installationId: string) => Promise<void>;
}
/**
* Transform GitHub API installation data to our simplified format
*/
const transformInstallation = (
installation: GitHubInstallation,
): Installation => {
if (!installation.account) {
throw new Error("Installation account is null");
}
// Handle both User and Organization account types
let accountName: string;
if ("login" in installation.account && installation.account.login) {
accountName = installation.account.login;
} else if ("slug" in installation.account && installation.account.slug) {
accountName = installation.account.slug;
} else if ("name" in installation.account && installation.account.name) {
accountName = installation.account.name;
} else {
accountName = "Unknown";
}
const accountType = installation.target_type as "User" | "Organization";
return {
id: installation.id,
accountName,
accountType,
avatarUrl: installation.account.avatar_url,
};
};
/**
* Hook for managing GitHub App installations
* Fetches installation data from the API endpoint and reads current installation ID from cookies
* Provides functions to switch between installations
*/
export function useGitHubInstallations(): UseGitHubInstallationsReturn {
const [installations, setInstallations] = useState<Installation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentInstallationId, setCurrentInstallationId] = useState<
string | null
>(null);
// Get current installation ID from the cookie
const getCurrentInstallationId = useCallback((): string | null => {
return getCookie(GITHUB_INSTALLATION_ID_COOKIE);
}, []);
// Fetch installations from API
const fetchInstallations = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch("/api/github/installations");
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data: GitHubInstallationsResponse = await response.json();
const transformedInstallations = data.installations.map(
transformInstallation,
);
setInstallations(transformedInstallations);
// Get the current installation ID from the cookie
const currentId = getCurrentInstallationId();
setCurrentInstallationId(currentId);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch installations";
setError(errorMessage);
setInstallations([]);
} finally {
setIsLoading(false);
}
}, [getCurrentInstallationId]);
// Switch installation function - now uses API endpoint
const switchInstallation = useCallback(async (installationId: string) => {
try {
const response = await fetch("/api/github/switch-installation", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ installationId }),
});
if (response.ok) {
// Update local state immediately for responsive UI
setCurrentInstallationId(installationId);
} else {
console.error("Failed to switch installation");
}
} catch (error) {
console.error("Error switching installation:", error);
}
}, []);
// Auto-select default installation when installations are loaded
useEffect(() => {
if (installations.length > 0 && !isLoading) {
// Check if current installation ID is valid
const isCurrentInstallationValid =
currentInstallationId &&
installations.some(
(installation) =>
installation.id.toString() === currentInstallationId,
);
if (!isCurrentInstallationValid) {
// No valid installation selected, auto-select the first one
const firstInstallation = installations[0];
if (firstInstallation) {
switchInstallation(firstInstallation.id.toString());
}
}
}
}, [installations, isLoading, currentInstallationId, switchInstallation]);
// Initialize installation ID from cookie on mount
useEffect(() => {
const cookieInstallationId = getCurrentInstallationId();
setCurrentInstallationId(cookieInstallationId);
}, [getCurrentInstallationId]);
// Initial fetch on mount
useEffect(() => {
fetchInstallations();
}, [fetchInstallations]);
// Refresh installations function
const refreshInstallations = useCallback(async () => {
await fetchInstallations();
}, [fetchInstallations]);
// Refresh current installation ID from cookie
const refreshCurrentInstallation = useCallback(() => {
const cookieInstallationId = getCurrentInstallationId();
setCurrentInstallationId(cookieInstallationId);
}, [getCurrentInstallationId]);
// Find current installation object
const currentInstallation = currentInstallationId
? installations.find(
(installation) => installation.id.toString() === currentInstallationId,
) || null
: null;
return {
// Installation data
installations,
currentInstallationId,
currentInstallation,
// State management
isLoading,
error,
// Actions
refreshInstallations,
refreshCurrentInstallation,
switchInstallation,
};
}

View file

@ -28,6 +28,19 @@ function getCookieOptions(expires?: Date) {
};
}
/**
* Cookie options for GitHub installation ID cookie (non-HTTP-only for client access)
*/
export function getInstallationCookieOptions(expires?: Date) {
return {
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
*
@ -57,7 +70,7 @@ export function storeGitHubToken(
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
tokenData.installation_id,
cookieOptions,
getInstallationCookieOptions(),
);
}
}

View file

@ -1,6 +1,22 @@
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Utility function to get a cookie value by name from the client-side
*/
export function getCookie(name: string): string | null {
if (typeof document === "undefined") {
return null; // Return null during SSR
}
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
return null;
}

View file

@ -3533,6 +3533,7 @@ __metadata:
"@langchain/langgraph": ^0.3.3
"@langchain/langgraph-sdk": ^0.0.85
"@octokit/app": ^16.0.1
"@octokit/types": ^14.1.0
"@open-swe/shared": "*"
"@radix-ui/react-alert-dialog": ^1.1.14
"@radix-ui/react-avatar": ^1.1.3