mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
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:
parent
c8344beb88
commit
ba38da81c3
19 changed files with 803 additions and 329 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
apps/web/src/app/api/github/installations/route.ts
Normal file
55
apps/web/src/app/api/github/installations/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/web/src/app/api/github/switch-installation/route.ts
Normal file
37
apps/web/src/app/api/github/switch-installation/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
30
apps/web/src/components/github/github-page-loading.tsx
Normal file
30
apps/web/src/components/github/github-page-loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
142
apps/web/src/components/github/installation-selector.tsx
Normal file
142
apps/web/src/components/github/installation-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
203
apps/web/src/hooks/useGitHubInstallations.ts
Normal file
203
apps/web/src/hooks/useGitHubInstallations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue