diff --git a/apps/web/package.json b/apps/web/package.json index d4b839ab..9fdff400 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/(v2)/chat/threads/page.tsx b/apps/web/src/app/(v2)/chat/threads/page.tsx index 8fa03340..685e5897 100644 --- a/apps/web/src/app/(v2)/chat/threads/page.tsx +++ b/apps/web/src/app/(v2)/chat/threads/page.tsx @@ -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({ assistantId: MANAGER_GRAPH_ID, @@ -55,157 +57,174 @@ export default function AllThreadsPage() { }; return ( -
- {/* Header */} -
-
- -
-
- - All Threads - -
-
+ +
+ {/* Header */} +
+
+
- - {filteredThreads.length} threads +
+ + All Threads
- -
-
-
- - {/* Search and Filters */} -
-
-
- - setSearchQuery(e.target.value)} - className="border-border bg-background text-foreground placeholder:text-muted-foreground pl-10 dark:bg-gray-900" - /> -
-
- - Filter: - {( - [ - "all", - "running", - "completed", - "failed", - "pending", - ] as FilterStatus[] - ).map((status) => ( - - ))} -
-
-
- - {/* Content */} -
-
- {statusFilter === "all" ? ( - // Show grouped view when "all" is selected -
- {Object.entries(groupedThreads).map(([status, threads]) => { - if (threads.length === 0) return null; - return ( -
-
-

- {status} Threads -

- - {threads.length} - -
-
- {threads.map((thread) => ( - - ))} -
-
- ); - })} +
+
+ + {filteredThreads.length} threads + +
+
+ + +
- ) : ( - // Show flat list when specific status is selected -
- {filteredThreads.map((thread) => ( - +
+
+ + {/* Search and Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="border-border bg-background text-foreground placeholder:text-muted-foreground pl-10 dark:bg-gray-900" + /> +
+
+ + + Filter: + + {( + [ + "all", + "running", + "completed", + "failed", + "pending", + ] as FilterStatus[] + ).map((status) => ( + ))}
- )} +
+
- {filteredThreads.length === 0 && !threadsLoading && ( -
-
No threads found
-
- {searchQuery - ? "Try adjusting your search query" - : "No threads match the selected filter"} -
-
- )} - - {threadsLoading && threads.length === 0 && ( -
-
-

- Loading threads... -

+ {/* Content */} +
+
+ {statusFilter === "all" ? ( + // Show grouped view when "all" is selected +
+ {Object.entries(groupedThreads).map(([status, threads]) => { + if (threads.length === 0) return null; + return ( +
+
+

+ {status} Threads +

+ + {threads.length} + +
+
+ {threads.map((thread) => ( + + ))} +
+
+ ); + })}
+ ) : ( + // Show flat list when specific status is selected
- {Array.from({ length: 9 }).map((_, index) => ( - + {filteredThreads.map((thread) => ( + ))}
-
- )} + )} + + {filteredThreads.length === 0 && !threadsLoading && ( +
+
+ No threads found +
+
+ {searchQuery + ? "Try adjusting your search query" + : "No threads match the selected filter"} +
+
+ )} + + {threadsLoading && threads.length === 0 && ( +
+
+

+ Loading threads... +

+
+
+ {Array.from({ length: 9 }).map((_, index) => ( + + ))} +
+
+ )} +
-
+ + ); +} + +export default function AllThreadsPage() { + return ( + Loading...
}> + + ); } diff --git a/apps/web/src/app/api/auth/github/callback/route.ts b/apps/web/src/app/api/auth/github/callback/route.ts index b4d35185..c70339cf 100644 --- a/apps/web/src/app/api/auth/github/callback/route.ts +++ b/apps/web/src/app/api/auth/github/callback/route.ts @@ -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; diff --git a/apps/web/src/app/api/github/installation-callback/route.ts b/apps/web/src/app/api/github/installation-callback/route.ts index 6be0c98d..8ba70913 100644 --- a/apps/web/src/app/api/github/installation-callback/route.ts +++ b/apps/web/src/app/api/github/installation-callback/route.ts @@ -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; diff --git a/apps/web/src/app/api/github/installation/callback/route.ts b/apps/web/src/app/api/github/installation/callback/route.ts deleted file mode 100644 index 3134a8e5..00000000 --- a/apps/web/src/app/api/github/installation/callback/route.ts +++ /dev/null @@ -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), - ); - } -} diff --git a/apps/web/src/app/api/github/installations/route.ts b/apps/web/src/app/api/github/installations/route.ts new file mode 100644 index 00000000..a2168f9c --- /dev/null +++ b/apps/web/src/app/api/github/installations/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/github/switch-installation/route.ts b/apps/web/src/app/api/github/switch-installation/route.ts new file mode 100644 index 00000000..6eaaeeb6 --- /dev/null +++ b/apps/web/src/app/api/github/switch-installation/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/github/page.tsx b/apps/web/src/app/github/page.tsx index 1742593b..516b24eb 100644 --- a/apps/web/src/app/github/page.tsx +++ b/apps/web/src/app/github/page.tsx @@ -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([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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 (
-

GitHub Repository Access

+
+

GitHub Repository Access

+ {isInstalled && ( +
+ +
+ )} +
{error && (
@@ -82,25 +46,29 @@ export default function GitHubPage() {
) : ( -
+

Your Repositories

- {repositories.length === 0 ? ( -

+ {repositories.length === 0 && !isLoading ? ( +

No repositories found. Make sure you've granted access to at least - one repository. + one repository for the selected organization. +

+ ) : isLoading ? ( +

+ Loading repositories...

) : ( -
    +
      {repositories.map((repo) => (
    • {repo.full_name} -

      +

      {repo.description || "No description"}

      -
      +
      {repo.private ? "Private" : "Public"} @@ -136,9 +104,9 @@ export default function GitHubPage() {
      )} -
      +

      Manage GitHub App

      -

      +

      You can manage your GitHub App installation, including adding or removing repositories, through GitHub.

      @@ -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 @@ -154,3 +122,13 @@ export default function GitHubPage() {
      ); } + +export default function GitHubPage() { + return ( + }> + + + + + ); +} diff --git a/apps/web/src/components/github/auth-status.tsx b/apps/web/src/components/github/auth-status.tsx index 9fa51e44..ba39dd75 100644 --- a/apps/web/src/components/github/auth-status.tsx +++ b/apps/web/src/components/github/auth-status.tsx @@ -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(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 ( + + + + ); +} diff --git a/apps/web/src/components/github/github-oauth-button.tsx b/apps/web/src/components/github/github-oauth-button.tsx index ec9a545e..fe1a1947 100644 --- a/apps/web/src/components/github/github-oauth-button.tsx +++ b/apps/web/src/components/github/github-oauth-button.tsx @@ -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"); diff --git a/apps/web/src/components/github/github-page-loading.tsx b/apps/web/src/components/github/github-page-loading.tsx new file mode 100644 index 00000000..399242dd --- /dev/null +++ b/apps/web/src/components/github/github-page-loading.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +export function GithubPageLoading() { + return ( +
      +
      +

      GitHub Repository Access

      +
      +
      +
      +
      +

      Your Repositories

      +
      +
      +
      + {Array.from({ length: 3 }).map((_, index) => ( +
      +
      +
      +
      +
      + ))} +
      +
      +
      + ); +} diff --git a/apps/web/src/components/github/installation-banner.tsx b/apps/web/src/components/github/installation-banner.tsx index 2d117c0d..5caf2efa 100644 --- a/apps/web/src/components/github/installation-banner.tsx +++ b/apps/web/src/components/github/installation-banner.tsx @@ -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]); diff --git a/apps/web/src/components/github/installation-selector.tsx b/apps/web/src/components/github/installation-selector.tsx new file mode 100644 index 00000000..497c3b01 --- /dev/null +++ b/apps/web/src/components/github/installation-selector.tsx @@ -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" ? ( + + ) : ( + + ); + }; + + if (isLoading) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + if (installations.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/v2/default-view.tsx b/apps/web/src/components/v2/default-view.tsx index 863afd7c..9e86e8b5 100644 --- a/apps/web/src/components/v2/default-view.tsx +++ b/apps/web/src/components/v2/default-view.tsx @@ -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) { ready
      +
      diff --git a/apps/web/src/hooks/useGitHubApp.ts b/apps/web/src/hooks/useGitHubApp.ts index 4621d70c..3009828c 100644 --- a/apps/web/src/hooks/useGitHubApp.ts +++ b/apps/web/src/hooks/useGitHubApp.ts @@ -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; + refreshInstallations: () => Promise; + // 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(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, diff --git a/apps/web/src/hooks/useGitHubInstallations.ts b/apps/web/src/hooks/useGitHubInstallations.ts new file mode 100644 index 00000000..3b6cb24a --- /dev/null +++ b/apps/web/src/hooks/useGitHubInstallations.ts @@ -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; + refreshCurrentInstallation: () => void; + switchInstallation: (installationId: string) => Promise; +} + +/** + * 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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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, + }; +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index c733d376..97ee19c1 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -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(), ); } } diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index a5ef1935..d35ab936 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -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; +} diff --git a/yarn.lock b/yarn.lock index 513c8302..6ad4dcf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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