diff --git a/app/[owner]/[repo]/layout.tsx b/app/[owner]/[repo]/layout.tsx new file mode 100644 index 00000000..d3de785e --- /dev/null +++ b/app/[owner]/[repo]/layout.tsx @@ -0,0 +1,22 @@ +import { Metadata } from 'next' + +interface LayoutProps { + params: Promise<{ + owner: string + repo: string + }> + children: React.ReactNode +} + +export async function generateMetadata({ params }: LayoutProps): Promise { + const { owner, repo } = await params + + return { + title: `${owner}/${repo} - Coding Agent`, + description: `Create AI-powered tasks for ${owner}/${repo}`, + } +} + +export default function Layout({ children }: LayoutProps) { + return children +} diff --git a/app/[owner]/[repo]/page.tsx b/app/[owner]/[repo]/page.tsx new file mode 100644 index 00000000..96d3bbaf --- /dev/null +++ b/app/[owner]/[repo]/page.tsx @@ -0,0 +1,41 @@ +import { cookies } from 'next/headers' +import { HomePageContent } from '@/components/home-page-content' +import { getServerSession } from '@/lib/session/get-server-session' +import { getGitHubStars } from '@/lib/github-stars' +import { getMaxSandboxDuration } from '@/lib/db/settings' + +interface OwnerRepoPageProps { + params: Promise<{ + owner: string + repo: string + }> +} + +export default async function OwnerRepoPage({ params }: OwnerRepoPageProps) { + const { owner, repo } = await params + + const cookieStore = await cookies() + const installDependencies = cookieStore.get('install-dependencies')?.value === 'true' + const keepAlive = cookieStore.get('keep-alive')?.value === 'true' + + const session = await getServerSession() + + // Get max sandbox duration for this user (user-specific > global > env var) + const maxSandboxDuration = await getMaxSandboxDuration(session?.user?.id) + const maxDuration = parseInt(cookieStore.get('max-duration')?.value || maxSandboxDuration.toString(), 10) + + const stars = await getGitHubStars() + + return ( + + ) +} diff --git a/app/api/github/verify-repo/route.ts b/app/api/github/verify-repo/route.ts new file mode 100644 index 00000000..a47c6ca0 --- /dev/null +++ b/app/api/github/verify-repo/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getUserGitHubToken } from '@/lib/github/user-token' + +export async function GET(request: NextRequest) { + try { + const token = await getUserGitHubToken(request) + + if (!token) { + return NextResponse.json({ error: 'GitHub not connected' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const owner = searchParams.get('owner') + const repo = searchParams.get('repo') + + if (!owner || !repo) { + return NextResponse.json({ error: 'Owner and repo parameters are required' }, { status: 400 }) + } + + // Try to fetch the repository to check if it's accessible + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + if (!response.ok) { + if (response.status === 404) { + return NextResponse.json({ accessible: false, error: 'Repository not found' }, { status: 200 }) + } + return NextResponse.json({ accessible: false, error: 'Failed to verify repository' }, { status: 200 }) + } + + const repoData = await response.json() + + // Return repo info including owner details + return NextResponse.json({ + accessible: true, + owner: { + login: repoData.owner.login, + name: repoData.owner.login, // Use login as name if name is not available + avatar_url: repoData.owner.avatar_url, + }, + repo: { + name: repoData.name, + full_name: repoData.full_name, + description: repoData.description, + private: repoData.private, + clone_url: repoData.clone_url, + language: repoData.language, + }, + }) + } catch (error) { + console.error('Error verifying GitHub repository:', error) + return NextResponse.json({ accessible: false, error: 'Failed to verify repository' }, { status: 500 }) + } +} diff --git a/app/new/[owner]/[repo]/layout.tsx b/app/new/[owner]/[repo]/layout.tsx new file mode 100644 index 00000000..d3de785e --- /dev/null +++ b/app/new/[owner]/[repo]/layout.tsx @@ -0,0 +1,22 @@ +import { Metadata } from 'next' + +interface LayoutProps { + params: Promise<{ + owner: string + repo: string + }> + children: React.ReactNode +} + +export async function generateMetadata({ params }: LayoutProps): Promise { + const { owner, repo } = await params + + return { + title: `${owner}/${repo} - Coding Agent`, + description: `Create AI-powered tasks for ${owner}/${repo}`, + } +} + +export default function Layout({ children }: LayoutProps) { + return children +} diff --git a/app/new/[owner]/[repo]/page.tsx b/app/new/[owner]/[repo]/page.tsx new file mode 100644 index 00000000..ce3b1daf --- /dev/null +++ b/app/new/[owner]/[repo]/page.tsx @@ -0,0 +1,41 @@ +import { cookies } from 'next/headers' +import { HomePageContent } from '@/components/home-page-content' +import { getServerSession } from '@/lib/session/get-server-session' +import { getGitHubStars } from '@/lib/github-stars' +import { getMaxSandboxDuration } from '@/lib/db/settings' + +interface NewRepoPageProps { + params: Promise<{ + owner: string + repo: string + }> +} + +export default async function NewRepoPage({ params }: NewRepoPageProps) { + const { owner, repo } = await params + + const cookieStore = await cookies() + const installDependencies = cookieStore.get('install-dependencies')?.value === 'true' + const keepAlive = cookieStore.get('keep-alive')?.value === 'true' + + const session = await getServerSession() + + // Get max sandbox duration for this user (user-specific > global > env var) + const maxSandboxDuration = await getMaxSandboxDuration(session?.user?.id) + const maxDuration = parseInt(cookieStore.get('max-duration')?.value || maxSandboxDuration.toString(), 10) + + const stars = await getGitHubStars() + + return ( + + ) +} diff --git a/components/home-page-content.tsx b/components/home-page-content.tsx index 76401d58..1ed9e0cc 100644 --- a/components/home-page-content.tsx +++ b/components/home-page-content.tsx @@ -286,8 +286,8 @@ export function HomePageContent({ /> - {/* Mobile Footer with Stars and Deploy Button - Only show when logged in */} - {user && } + {/* Mobile Footer with Stars and Deploy Button - Show when logged in OR when owner/repo are selected */} + {(user || selectedOwner || selectedRepo) && } {/* Sign In Dialog */} diff --git a/components/home-page-header.tsx b/components/home-page-header.tsx index 8c6e4e89..329bb6c8 100644 --- a/components/home-page-header.tsx +++ b/components/home-page-header.tsx @@ -191,13 +191,13 @@ export function HomePageHeader({ const actions = (
- {/* GitHub Stars Button - Show on mobile only when logged out, always show on desktop */} -
+ {/* GitHub Stars Button - Show on mobile only when logged out (unless owner/repo selected), always show on desktop */} +
- {/* Deploy to Vercel Button - Show on mobile only when logged out, always show on desktop */} -
+ {/* Deploy to Vercel Button - Show on mobile only when logged out (unless owner/repo selected), always show on desktop */} +
+ ) : selectedOwner || selectedRepo ? ( + // Show RepoSelector when logged out if owner/repo are provided via URL + ) : null}
) diff --git a/components/repo-selector.tsx b/components/repo-selector.tsx index c898638b..d356c393 100644 --- a/components/repo-selector.tsx +++ b/components/repo-selector.tsx @@ -49,6 +49,8 @@ export function RepoSelector({ const [loadingRepos, setLoadingRepos] = useState(false) const [repoDropdownOpen, setRepoDropdownOpen] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) + const [temporaryOwner, setTemporaryOwner] = useState(null) + const [temporaryRepo, setTemporaryRepo] = useState(null) // Ref for the filter input to focus it when dropdown opens const filterInputRef = useRef(null) @@ -68,9 +70,14 @@ export function RepoSelector({ // Since we can't clear all atomFamily members easily, we'll just clear the current one setRepos(null) - // Clear state - onOwnerChange('') - onRepoChange('') + // Don't clear state - keep the temporary owner/repo if they exist + // Only clear if no temporary owner/repo exists + if (!temporaryOwner) { + onOwnerChange('') + } + if (!temporaryRepo) { + onRepoChange('') + } } // If GitHub was reconnected, reload owners @@ -78,10 +85,13 @@ export function RepoSelector({ setLoadingOwners(true) setOwners(null) setRepos(null) + // Clear temporary owner/repo when reconnecting since we'll load real data + setTemporaryOwner(null) + setTemporaryRepo(null) } githubConnectionRef.current = githubConnection.connected - }, [githubConnection.connected, onOwnerChange, onRepoChange, setOwners, setRepos]) + }, [githubConnection.connected, onOwnerChange, onRepoChange, setOwners, setRepos, temporaryOwner, temporaryRepo]) // Load owners on component mount and when GitHub is connected useEffect(() => { @@ -187,6 +197,80 @@ export function RepoSelector({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [githubConnection.connected, setGitHubConnection, setOwners]) + // Check if a selected owner/repo is accessible even if not in the user's scopes + // OR create a placeholder owner when signed out + useEffect(() => { + const verifyExternalRepo = async () => { + // If not connected but owner is selected, create a placeholder + if (!githubConnection.connected && selectedOwner && temporaryOwner?.login !== selectedOwner) { + setTemporaryOwner({ + login: selectedOwner, + name: selectedOwner, + avatar_url: `https://github.com/${selectedOwner}.png`, + }) + + // Also create a temporary repo if repo is selected + if (selectedRepo && temporaryRepo?.name !== selectedRepo) { + setTemporaryRepo({ + name: selectedRepo, + full_name: `${selectedOwner}/${selectedRepo}`, + description: '', + private: false, + clone_url: `https://github.com/${selectedOwner}/${selectedRepo}.git`, + language: '', + }) + } + return + } + + // Only verify if: + // 1. GitHub is connected + // 2. Both owner and repo are selected + // 3. Owner is not in the owners list + // 4. Owner is not already the temporary owner + if ( + !githubConnection.connected || + !selectedOwner || + !selectedRepo || + !owners || + owners.some((o) => o.login === selectedOwner) || + temporaryOwner?.login === selectedOwner + ) { + return + } + + try { + const response = await fetch(`/api/github/verify-repo?owner=${selectedOwner}&repo=${selectedRepo}`) + + if (response.ok) { + const data = await response.json() + if (data.accessible && data.owner) { + // Temporarily add this owner to the list + setTemporaryOwner(data.owner) + // Also add the repo to temporary repos + if (data.repo) { + setTemporaryRepo(data.repo) + } + } else { + // Repo is not accessible, clear temporary owner + setTemporaryOwner(null) + setTemporaryRepo(null) + } + } else { + // Failed to verify, clear temporary owner + setTemporaryOwner(null) + setTemporaryRepo(null) + } + } catch (error) { + console.error('Error verifying external repo:', error) + setTemporaryOwner(null) + setTemporaryRepo(null) + } + } + + verifyExternalRepo() + }, [selectedOwner, selectedRepo, owners, githubConnection.connected, temporaryOwner?.login, temporaryRepo?.name]) + // Auto-select user's personal account if no owner is selected and no saved owner exists useEffect(() => { if (owners && owners.length > 0 && !selectedOwner) { @@ -302,6 +386,11 @@ export function RepoSelector({ repo.description?.toLowerCase().includes(repoFilter.toLowerCase()), ) + // Add temporary repo if it exists and is not in the repos list + if (temporaryRepo && !filteredRepos.some((r) => r.name === temporaryRepo.name)) { + filteredRepos.unshift(temporaryRepo) + } + // Show first 50 filtered repos, but always include the selected repo if it exists let displayedRepos = filteredRepos.slice(0, 50) const hasMoreRepos = filteredRepos.length > 50 @@ -322,6 +411,8 @@ export function RepoSelector({ onRepoChange('') // Reset repo when owner changes setRepoFilter('') // Reset filter when owner changes setRepos(null) // Clear repos to trigger loading state for new owner + setTemporaryOwner(null) // Clear temporary owner when user manually changes + setTemporaryRepo(null) // Clear temporary repo when owner changes } const handleRepoChange = (value: string) => { @@ -339,11 +430,39 @@ export function RepoSelector({ : 'w-auto min-w-[160px] border-0 bg-transparent shadow-none focus:ring-0 h-8' // Find the selected owner for avatar display - const selectedOwnerData = owners?.find((owner) => owner.login === selectedOwner) + const selectedOwnerData = owners?.find((owner) => owner.login === selectedOwner) || temporaryOwner + + // Combine owners with temporary owner if needed + const displayedOwners = (() => { + // If no owners but we have a temporary owner (logged out case), show just the temporary owner + if (!owners && temporaryOwner) { + return [temporaryOwner] + } + + if (!owners) return null + + // If temporary owner exists and is not in owners list, add it + if (temporaryOwner && !owners.some((o) => o.login === temporaryOwner.login)) { + // Find the position to insert (keep it sorted) + const insertIndex = owners.findIndex( + (o) => o.login.toLowerCase() > temporaryOwner.login.toLowerCase() && o.login !== owners[0]?.login, + ) + + if (insertIndex === -1) { + // Add at the end + return [...owners, temporaryOwner] + } else { + // Insert at the correct position + return [...owners.slice(0, insertIndex), temporaryOwner, ...owners.slice(insertIndex)] + } + } + + return owners + })() // Determine if we should show loading indicators - const showOwnersLoading = loadingOwners && (!owners || owners.length === 0) - const showReposLoading = loadingRepos && (!repos || repos.length === 0) + const showOwnersLoading = loadingOwners && (!owners || owners.length === 0) && !temporaryOwner + const showReposLoading = loadingRepos && (!repos || repos.length === 0) && !temporaryRepo return (
@@ -373,8 +492,8 @@ export function RepoSelector({ )} - {owners && - owners.map((owner) => ( + {displayedOwners && + displayedOwners.map((owner) => (