diff --git a/auto-claude-ui/src/main/ipc-handlers/github/ARCHITECTURE.md b/auto-claude-ui/src/main/ipc-handlers/github/ARCHITECTURE.md index 1e6d3df3..c902471a 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/ARCHITECTURE.md +++ b/auto-claude-ui/src/main/ipc-handlers/github/ARCHITECTURE.md @@ -59,6 +59,15 @@ │ │ • Check authentication status │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ oauth-handlers.ts (220 lines) │ │ +│ │ • Check gh CLI installation │ │ +│ │ • Check authentication status │ │ +│ │ • Start OAuth flow via gh CLI │ │ +│ │ • Retrieve OAuth tokens │ │ +│ │ • Get authenticated user info │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ └─────────────────────────────────────────────────────────────────────┘ │ │ depends on @@ -68,8 +77,9 @@ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ -│ │ utils.ts (60 lines) │ │ +│ │ utils.ts (85 lines) │ │ │ │ • getGitHubConfig() - Extract config from .env │ │ +│ │ • getTokenFromGhCli() - Get token from gh CLI │ │ │ │ • githubFetch() - GitHub API wrapper │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ diff --git a/auto-claude-ui/src/main/ipc-handlers/github/index.ts b/auto-claude-ui/src/main/ipc-handlers/github/index.ts index 659651f2..5534a342 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/index.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/index.ts @@ -8,6 +8,7 @@ * - investigation-handlers: AI-powered issue investigation * - import-handlers: Bulk issue import * - release-handlers: GitHub release creation + * - oauth-handlers: GitHub CLI OAuth authentication */ import type { BrowserWindow } from 'electron'; @@ -17,6 +18,7 @@ import { registerIssueHandlers } from './issue-handlers'; import { registerInvestigationHandlers } from './investigation-handlers'; import { registerImportHandlers } from './import-handlers'; import { registerReleaseHandlers } from './release-handlers'; +import { registerGithubOAuthHandlers } from './oauth-handlers'; /** * Register all GitHub-related IPC handlers @@ -30,6 +32,7 @@ export function registerGithubHandlers( registerInvestigationHandlers(agentManager, getMainWindow); registerImportHandlers(agentManager); registerReleaseHandlers(); + registerGithubOAuthHandlers(); } // Re-export utilities for potential external use diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts new file mode 100644 index 00000000..d5e9c3fb --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -0,0 +1,311 @@ +/** + * GitHub OAuth handlers using GitHub CLI (gh) + * Provides a simpler OAuth flow than manual PAT creation + */ + +import { ipcMain } from 'electron'; +import { execSync, spawn } from 'child_process'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult } from '../../../shared/types'; + +// Debug logging helper +const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + +function debugLog(message: string, data?: unknown): void { + if (DEBUG) { + if (data !== undefined) { + console.log(`[GitHub OAuth] ${message}`, data); + } else { + console.log(`[GitHub OAuth] ${message}`); + } + } +} + +/** + * Check if gh CLI is installed + */ +export function registerCheckGhCli(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_CLI, + async (): Promise> => { + debugLog('checkGitHubCli handler called'); + try { + const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh'; + debugLog(`Running command: ${checkCmd}`); + + const whichResult = execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' }); + debugLog('gh CLI found at:', whichResult.trim()); + + // Get version + debugLog('Getting gh version...'); + const versionOutput = execSync('gh --version', { encoding: 'utf-8', stdio: 'pipe' }); + const version = versionOutput.trim().split('\n')[0]; + debugLog('gh version:', version); + + return { + success: true, + data: { installed: true, version } + }; + } catch (error) { + debugLog('gh CLI not found or error:', error instanceof Error ? error.message : error); + return { + success: true, + data: { installed: false } + }; + } + } + ); +} + +/** + * Check if user is authenticated with gh CLI + */ +export function registerCheckGhAuth(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_AUTH, + async (): Promise> => { + debugLog('checkGitHubAuth handler called'); + try { + // Check auth status + debugLog('Running: gh auth status'); + const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' }); + debugLog('Auth status output:', authStatus); + + // Get username if authenticated + try { + debugLog('Getting username via: gh api user --jq .login'); + const username = execSync('gh api user --jq .login', { + encoding: 'utf-8', + stdio: 'pipe' + }).trim(); + debugLog('Username:', username); + + return { + success: true, + data: { authenticated: true, username } + }; + } catch (usernameError) { + debugLog('Could not get username:', usernameError instanceof Error ? usernameError.message : usernameError); + return { + success: true, + data: { authenticated: true } + }; + } + } catch (error) { + debugLog('Auth check failed (not authenticated):', error instanceof Error ? error.message : error); + return { + success: true, + data: { authenticated: false } + }; + } + } + ); +} + +/** + * Start GitHub OAuth flow using gh CLI + * This will open the browser for device flow authentication + */ +export function registerStartGhAuth(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_START_AUTH, + async (): Promise> => { + debugLog('startGitHubAuth handler called'); + return new Promise((resolve) => { + try { + // Use gh auth login with web flow and repo scope + const args = ['auth', 'login', '--web', '--scopes', 'repo']; + debugLog('Spawning: gh', args); + + const ghProcess = spawn('gh', args, { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let output = ''; + let errorOutput = ''; + + ghProcess.stdout?.on('data', (data) => { + const chunk = data.toString(); + output += chunk; + debugLog('gh stdout:', chunk); + }); + + ghProcess.stderr?.on('data', (data) => { + const chunk = data.toString(); + errorOutput += chunk; + debugLog('gh stderr:', chunk); + }); + + ghProcess.on('close', (code) => { + debugLog('gh process exited with code:', code); + debugLog('Full stdout:', output); + debugLog('Full stderr:', errorOutput); + + if (code === 0) { + resolve({ + success: true, + data: { + success: true, + message: 'Successfully authenticated with GitHub' + } + }); + } else { + resolve({ + success: false, + error: errorOutput || `Authentication failed with exit code ${code}` + }); + } + }); + + ghProcess.on('error', (error) => { + debugLog('gh process error:', error.message); + resolve({ + success: false, + error: error.message + }); + }); + } catch (error) { + debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error); + resolve({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + } + ); +} + +/** + * Get the current GitHub auth token from gh CLI + */ +export function registerGetGhToken(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_TOKEN, + async (): Promise> => { + debugLog('getGitHubToken handler called'); + try { + debugLog('Running: gh auth token'); + const token = execSync('gh auth token', { + encoding: 'utf-8', + stdio: 'pipe' + }).trim(); + + if (!token) { + debugLog('No token returned (empty string)'); + return { + success: false, + error: 'No token found. Please authenticate first.' + }; + } + + debugLog('Token retrieved successfully, length:', token.length); + return { + success: true, + data: { token } + }; + } catch (error) { + debugLog('Failed to get token:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get token' + }; + } + } + ); +} + +/** + * Get the authenticated GitHub user info + */ +export function registerGetGhUser(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_USER, + async (): Promise> => { + debugLog('getGitHubUser handler called'); + try { + debugLog('Running: gh api user'); + const userJson = execSync('gh api user', { + encoding: 'utf-8', + stdio: 'pipe' + }); + + debugLog('User API response received'); + const user = JSON.parse(userJson); + debugLog('Parsed user:', { login: user.login, name: user.name }); + + return { + success: true, + data: { + username: user.login, + name: user.name + } + }; + } catch (error) { + debugLog('Failed to get user info:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get user info' + }; + } + } + ); +} + +/** + * List repositories accessible to the authenticated user + */ +export function registerListUserRepos(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_LIST_USER_REPOS, + async (): Promise }>> => { + debugLog('listUserRepos handler called'); + try { + // Use gh repo list to get user's repositories + // Format: owner/repo, description, visibility + debugLog('Running: gh repo list --limit 100 --json nameWithOwner,description,isPrivate'); + const output = execSync( + 'gh repo list --limit 100 --json nameWithOwner,description,isPrivate', + { + encoding: 'utf-8', + stdio: 'pipe' + } + ); + + const repos = JSON.parse(output); + debugLog('Found repos:', repos.length); + + const formattedRepos = repos.map((repo: { nameWithOwner: string; description: string | null; isPrivate: boolean }) => ({ + fullName: repo.nameWithOwner, + description: repo.description, + isPrivate: repo.isPrivate + })); + + return { + success: true, + data: { repos: formattedRepos } + }; + } catch (error) { + debugLog('Failed to list repos:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list repositories' + }; + } + } + ); +} + +/** + * Register all GitHub OAuth handlers + */ +export function registerGithubOAuthHandlers(): void { + debugLog('Registering GitHub OAuth handlers'); + registerCheckGhCli(); + registerCheckGhAuth(); + registerStartGhAuth(); + registerGetGhToken(); + registerGetGhUser(); + registerListUserRepos(); + debugLog('GitHub OAuth handlers registered'); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/github/utils.ts b/auto-claude-ui/src/main/ipc-handlers/github/utils.ts index 395cfd45..1e0a4285 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/utils.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/utils.ts @@ -3,13 +3,30 @@ */ import { existsSync, readFileSync } from 'fs'; +import { execSync } from 'child_process'; import path from 'path'; import type { Project } from '../../../shared/types'; import { parseEnvFile } from '../utils'; import type { GitHubConfig } from './types'; +/** + * Get GitHub token from gh CLI if available + */ +function getTokenFromGhCli(): string | null { + try { + const token = execSync('gh auth token', { + encoding: 'utf-8', + stdio: 'pipe' + }).trim(); + return token || null; + } catch { + return null; + } +} + /** * Get GitHub configuration from project environment file + * Falls back to gh CLI token if GITHUB_TOKEN not in .env */ export function getGitHubConfig(project: Project): GitHubConfig | null { if (!project.autoBuildPath) return null; @@ -19,9 +36,17 @@ export function getGitHubConfig(project: Project): GitHubConfig | null { try { const content = readFileSync(envPath, 'utf-8'); const vars = parseEnvFile(content); - const token = vars['GITHUB_TOKEN']; + let token: string | undefined = vars['GITHUB_TOKEN']; const repo = vars['GITHUB_REPO']; + // If no token in .env, try to get it from gh CLI + if (!token) { + const ghToken = getTokenFromGhCli(); + if (ghToken) { + token = ghToken; + } + } + if (!token || !repo) return null; return { token, repo }; } catch { diff --git a/auto-claude-ui/src/preload/api/modules/github-api.ts b/auto-claude-ui/src/preload/api/modules/github-api.ts index a6ad0089..dc558341 100644 --- a/auto-claude-ui/src/preload/api/modules/github-api.ts +++ b/auto-claude-ui/src/preload/api/modules/github-api.ts @@ -28,6 +28,14 @@ export interface GitHubAPI { options?: { draft?: boolean; prerelease?: boolean } ) => Promise>; + // OAuth operations (gh CLI) + checkGitHubCli: () => Promise>; + checkGitHubAuth: () => Promise>; + startGitHubAuth: () => Promise>; + getGitHubToken: () => Promise>; + getGitHubUser: () => Promise>; + listGitHubUserRepos: () => Promise }>>; + // Event Listeners onGitHubInvestigationProgress: ( callback: (projectId: string, status: GitHubInvestigationStatus) => void @@ -71,6 +79,25 @@ export const createGitHubAPI = (): GitHubAPI => ({ ): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options), + // OAuth operations (gh CLI) + checkGitHubCli: (): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CLI), + + checkGitHubAuth: (): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_CHECK_AUTH), + + startGitHubAuth: (): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_START_AUTH), + + getGitHubToken: (): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_TOKEN), + + getGitHubUser: (): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_USER), + + listGitHubUserRepos: (): Promise }>> => + invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS), + // Event Listeners onGitHubInvestigationProgress: ( callback: (projectId: string, status: GitHubInvestigationStatus) => void diff --git a/auto-claude-ui/src/renderer/App.tsx b/auto-claude-ui/src/renderer/App.tsx index a4712a44..14114e56 100644 --- a/auto-claude-ui/src/renderer/App.tsx +++ b/auto-claude-ui/src/renderer/App.tsx @@ -20,6 +20,7 @@ import { KanbanBoard } from './components/KanbanBoard'; import { TaskDetailPanel } from './components/TaskDetailPanel'; import { TaskCreationWizard } from './components/TaskCreationWizard'; import { AppSettingsDialog, type AppSection } from './components/settings/AppSettings'; +import type { ProjectSettingsSection } from './components/settings/ProjectSettingsContent'; import { TerminalGrid } from './components/TerminalGrid'; import { Roadmap } from './components/Roadmap'; import { Context } from './components/Context'; @@ -56,6 +57,7 @@ export function App() { const [isNewTaskDialogOpen, setIsNewTaskDialogOpen] = useState(false); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState(undefined); + const [settingsInitialProjectSection, setSettingsInitialProjectSection] = useState(undefined); const [activeView, setActiveView] = useState('kanban'); const [isOnboardingWizardOpen, setIsOnboardingWizardOpen] = useState(false); @@ -319,7 +321,10 @@ export function App() { )} {activeView === 'github-issues' && selectedProjectId && ( - setIsSettingsDialogOpen(true)} /> + { + setSettingsInitialProjectSection('github'); + setIsSettingsDialogOpen(true); + }} /> )} {activeView === 'changelog' && selectedProjectId && ( @@ -371,11 +376,13 @@ export function App() { onOpenChange={(open) => { setIsSettingsDialogOpen(open); if (!open) { - // Reset initial section when dialog closes + // Reset initial sections when dialog closes setSettingsInitialSection(undefined); + setSettingsInitialProjectSection(undefined); } }} initialSection={settingsInitialSection} + initialProjectSection={settingsInitialProjectSection} onRerunWizard={() => { // Reset onboarding state to trigger wizard useSettingsStore.getState().updateSettings({ onboardingCompleted: false }); diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubIntegrationSection.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubIntegrationSection.tsx index b32d9708..047182f8 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubIntegrationSection.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubIntegrationSection.tsx @@ -1,12 +1,15 @@ -import { Github, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { Github, RefreshCw, KeyRound } from 'lucide-react'; import { CollapsibleSection } from './CollapsibleSection'; import { StatusBadge } from './StatusBadge'; import { PasswordInput } from './PasswordInput'; import { ConnectionStatus } from './ConnectionStatus'; +import { GitHubOAuthFlow } from './GitHubOAuthFlow'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Switch } from '../ui/switch'; import { Separator } from '../ui/separator'; +import { Button } from '../ui/button'; import type { ProjectEnvConfig, GitHubSyncStatus } from '../../../shared/types'; interface GitHubIntegrationSectionProps { @@ -26,10 +29,17 @@ export function GitHubIntegrationSection({ gitHubConnectionStatus, isCheckingGitHub, }: GitHubIntegrationSectionProps) { + const [showOAuthFlow, setShowOAuthFlow] = useState(false); + const badge = envConfig.githubEnabled ? ( ) : null; + const handleOAuthSuccess = (token: string, _username?: string) => { + onUpdateConfig({ githubToken: token }); + setShowOAuthFlow(false); + }; + return ( -
- -

- Create a token with repo scope from{' '} - - GitHub Settings - -

- onUpdateConfig({ githubToken: value })} - placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx" - /> -
+ {showOAuthFlow ? ( +
+
+ + +
+ setShowOAuthFlow(false)} + /> +
+ ) : ( +
+
+ + +
+

+ Create a token with repo scope from{' '} + + GitHub Settings + +

+ onUpdateConfig({ githubToken: value })} + placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx" + /> +
+ )}
diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx new file mode 100644 index 00000000..a14a4ec1 --- /dev/null +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -0,0 +1,341 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Github, + Loader2, + CheckCircle2, + AlertCircle, + Info, + ExternalLink, + Terminal +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Card, CardContent } from '../ui/card'; + +interface GitHubOAuthFlowProps { + onSuccess: (token: string, username?: string) => void; + onCancel?: () => void; +} + +// Debug logging helper - logs when DEBUG env var is set or in development +const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true'; + +function debugLog(message: string, data?: unknown) { + if (DEBUG) { + if (data !== undefined) { + console.log(`[GitHubOAuth] ${message}`, data); + } else { + console.log(`[GitHubOAuth] ${message}`); + } + } +} + +/** + * GitHub OAuth flow component using gh CLI + * Guides users through authenticating with GitHub using the gh CLI + */ +export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { + const [status, setStatus] = useState<'checking' | 'need-install' | 'need-auth' | 'authenticating' | 'success' | 'error'>('checking'); + const [error, setError] = useState(null); + const [cliInstalled, setCliInstalled] = useState(false); + const [cliVersion, setCliVersion] = useState(); + const [username, setUsername] = useState(); + + // Check gh CLI installation and authentication status on mount + // Use a ref to prevent double-execution in React Strict Mode + const hasCheckedRef = useRef(false); + + useEffect(() => { + if (hasCheckedRef.current) { + debugLog('Skipping duplicate check (Strict Mode)'); + return; + } + hasCheckedRef.current = true; + debugLog('Component mounted, checking GitHub status...'); + checkGitHubStatus(); + }, []); + + const checkGitHubStatus = async () => { + debugLog('checkGitHubStatus() called'); + setStatus('checking'); + setError(null); + + try { + // Check if gh CLI is installed + debugLog('Calling checkGitHubCli...'); + const cliResult = await window.electronAPI.checkGitHubCli(); + debugLog('checkGitHubCli result:', cliResult); + + if (!cliResult.success) { + debugLog('checkGitHubCli failed:', cliResult.error); + setError(cliResult.error || 'Failed to check GitHub CLI'); + setStatus('error'); + return; + } + + if (!cliResult.data?.installed) { + debugLog('GitHub CLI not installed'); + setStatus('need-install'); + setCliInstalled(false); + return; + } + + setCliInstalled(true); + setCliVersion(cliResult.data.version); + debugLog('GitHub CLI installed, version:', cliResult.data.version); + + // Check if already authenticated + debugLog('Calling checkGitHubAuth...'); + const authResult = await window.electronAPI.checkGitHubAuth(); + debugLog('checkGitHubAuth result:', authResult); + + if (authResult.success && authResult.data?.authenticated) { + debugLog('Already authenticated as:', authResult.data.username); + setUsername(authResult.data.username); + // Get the token and notify parent + await fetchAndNotifyToken(); + } else { + debugLog('Not authenticated, showing auth prompt'); + setStatus('need-auth'); + } + } catch (err) { + debugLog('Error in checkGitHubStatus:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + setStatus('error'); + } + }; + + const fetchAndNotifyToken = async () => { + debugLog('fetchAndNotifyToken() called'); + try { + debugLog('Calling getGitHubToken...'); + const tokenResult = await window.electronAPI.getGitHubToken(); + debugLog('getGitHubToken result:', { + success: tokenResult.success, + hasToken: !!tokenResult.data?.token, + tokenLength: tokenResult.data?.token?.length, + error: tokenResult.error + }); + + if (tokenResult.success && tokenResult.data?.token) { + debugLog('Token retrieved successfully, calling onSuccess with username:', username); + setStatus('success'); + onSuccess(tokenResult.data.token, username); + } else { + debugLog('Failed to get token:', tokenResult.error); + setError(tokenResult.error || 'Failed to get token'); + setStatus('error'); + } + } catch (err) { + debugLog('Error in fetchAndNotifyToken:', err); + setError(err instanceof Error ? err.message : 'Failed to get token'); + setStatus('error'); + } + }; + + const handleStartAuth = async () => { + debugLog('handleStartAuth() called'); + setStatus('authenticating'); + setError(null); + + try { + debugLog('Calling startGitHubAuth...'); + const result = await window.electronAPI.startGitHubAuth(); + debugLog('startGitHubAuth result:', result); + + if (result.success && result.data?.success) { + debugLog('Auth successful, fetching token...'); + // Fetch the token and notify parent + await fetchAndNotifyToken(); + } else { + debugLog('Auth failed:', result.error); + setError(result.error || 'Authentication failed'); + setStatus('error'); + } + } catch (err) { + debugLog('Error in handleStartAuth:', err); + setError(err instanceof Error ? err.message : 'Authentication failed'); + setStatus('error'); + } + }; + + const handleOpenGhInstall = () => { + debugLog('Opening gh CLI install page'); + window.open('https://cli.github.com/', '_blank'); + }; + + const handleRetry = () => { + debugLog('Retry clicked'); + checkGitHubStatus(); + }; + + debugLog('Rendering with status:', status); + + return ( +
+ {/* Checking status */} + {status === 'checking' && ( +
+ +
+ )} + + {/* Need to install gh CLI */} + {status === 'need-install' && ( +
+ + +
+ +
+

+ GitHub CLI Required +

+

+ The GitHub CLI (gh) is required for OAuth authentication. This provides a secure + way to authenticate without manually creating tokens. +

+
+ + +
+
+
+
+
+ + + +
+ +
+

Installation instructions:

+
    +
  • macOS: brew install gh
  • +
  • Windows: winget install GitHub.cli
  • +
  • Linux: Visit cli.github.com
  • +
+
+
+
+
+
+ )} + + {/* Need authentication */} + {status === 'need-auth' && ( +
+ + +
+ +
+

+ Connect to GitHub +

+

+ Click the button below to authenticate with GitHub. This will open your browser + where you can authorize the application. +

+ {cliVersion && ( +

+ Using GitHub CLI {cliVersion} +

+ )} +
+
+
+
+ +
+ +
+
+ )} + + {/* Authenticating */} + {status === 'authenticating' && ( + + +
+ +
+

+ Authenticating... +

+

+ Please complete the authentication in your browser. This window will update automatically. +

+
+
+
+
+ )} + + {/* Success */} + {status === 'success' && ( + + +
+ +
+

+ Successfully Connected +

+

+ {username ? `Connected as ${username}` : 'Your GitHub account is now connected'} +

+
+
+
+
+ )} + + {/* Error */} + {status === 'error' && error && ( +
+ + +
+ +
+

+ Authentication Failed +

+

{error}

+
+
+
+
+ +
+ + {onCancel && ( + + )} +
+
+ )} + + {/* Cancel button for non-error states */} + {status !== 'error' && status !== 'success' && onCancel && ( +
+ +
+ )} +
+ ); +} diff --git a/auto-claude-ui/src/renderer/components/settings/AppSettings.tsx b/auto-claude-ui/src/renderer/components/settings/AppSettings.tsx index beb58dfd..02162ddd 100644 --- a/auto-claude-ui/src/renderer/components/settings/AppSettings.tsx +++ b/auto-claude-ui/src/renderer/components/settings/AppSettings.tsx @@ -41,6 +41,7 @@ interface AppSettingsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; initialSection?: AppSection; + initialProjectSection?: ProjectSettingsSection; onRerunWizard?: () => void; } @@ -75,7 +76,7 @@ const projectNavItems: NavItem[] = [ * Main application settings dialog container * Coordinates app and project settings sections */ -export function AppSettingsDialog({ open, onOpenChange, initialSection, onRerunWizard }: AppSettingsDialogProps) { +export function AppSettingsDialog({ open, onOpenChange, initialSection, initialProjectSection, onRerunWizard }: AppSettingsDialogProps) { const { settings, setSettings, isSaving, error, saveSettings } = useSettings(); const [version, setVersion] = useState(''); @@ -86,11 +87,16 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, onRerunW // Navigate to initial section when dialog opens with a specific section useEffect(() => { - if (open && initialSection) { - setActiveTopLevel('app'); - setAppSection(initialSection); + if (open) { + if (initialProjectSection) { + setActiveTopLevel('project'); + setProjectSection(initialProjectSection); + } else if (initialSection) { + setActiveTopLevel('app'); + setAppSection(initialSection); + } } - }, [open, initialSection]); + }, [open, initialSection, initialProjectSection]); // Project state const projects = useProjectStore((state) => state.projects); diff --git a/auto-claude-ui/src/renderer/components/settings/integrations/GitHubIntegration.tsx b/auto-claude-ui/src/renderer/components/settings/integrations/GitHubIntegration.tsx index 8118ebb5..25aa01cf 100644 --- a/auto-claude-ui/src/renderer/components/settings/integrations/GitHubIntegration.tsx +++ b/auto-claude-ui/src/renderer/components/settings/integrations/GitHubIntegration.tsx @@ -1,10 +1,32 @@ -import { Github, RefreshCw, Eye, EyeOff, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown } from 'lucide-react'; import { Input } from '../../ui/input'; import { Label } from '../../ui/label'; import { Switch } from '../../ui/switch'; import { Separator } from '../../ui/separator'; +import { Button } from '../../ui/button'; +import { GitHubOAuthFlow } from '../../project-settings/GitHubOAuthFlow'; +import { PasswordInput } from '../../project-settings/PasswordInput'; import type { ProjectEnvConfig, GitHubSyncStatus } from '../../../../shared/types'; +// Debug logging +const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true'; +function debugLog(message: string, data?: unknown) { + if (DEBUG) { + if (data !== undefined) { + console.log(`[GitHubIntegration] ${message}`, data); + } else { + console.log(`[GitHubIntegration] ${message}`); + } + } +} + +interface GitHubRepo { + fullName: string; + description: string | null; + isPrivate: boolean; +} + interface GitHubIntegrationProps { envConfig: ProjectEnvConfig | null; updateEnvConfig: (updates: Partial) => void; @@ -16,7 +38,7 @@ interface GitHubIntegrationProps { /** * GitHub integration settings component. - * Manages GitHub token, repository configuration, and connection status. + * Manages GitHub token (manual or OAuth), repository configuration, and connection status. */ export function GitHubIntegration({ envConfig, @@ -26,7 +48,75 @@ export function GitHubIntegration({ gitHubConnectionStatus, isCheckingGitHub }: GitHubIntegrationProps) { - if (!envConfig) return null; + const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual'); + const [oauthUsername, setOauthUsername] = useState(null); + const [repos, setRepos] = useState([]); + const [isLoadingRepos, setIsLoadingRepos] = useState(false); + const [reposError, setReposError] = useState(null); + + debugLog('Render - authMode:', authMode); + debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken } : null); + + // Fetch repos when entering oauth-success mode + useEffect(() => { + if (authMode === 'oauth-success') { + fetchUserRepos(); + } + }, [authMode]); + + const fetchUserRepos = async () => { + debugLog('Fetching user repositories...'); + setIsLoadingRepos(true); + setReposError(null); + + try { + const result = await window.electronAPI.listGitHubUserRepos(); + debugLog('listGitHubUserRepos result:', result); + + if (result.success && result.data?.repos) { + setRepos(result.data.repos); + debugLog('Loaded repos:', result.data.repos.length); + } else { + setReposError(result.error || 'Failed to load repositories'); + } + } catch (err) { + debugLog('Error fetching repos:', err); + setReposError(err instanceof Error ? err.message : 'Failed to load repositories'); + } finally { + setIsLoadingRepos(false); + } + }; + + if (!envConfig) { + debugLog('No envConfig, returning null'); + return null; + } + + const handleOAuthSuccess = (token: string, username?: string) => { + debugLog('handleOAuthSuccess called with token length:', token.length); + debugLog('OAuth username:', username); + + // Update the token + updateEnvConfig({ githubToken: token }); + + // Show success state with username + setOauthUsername(username || null); + setAuthMode('oauth-success'); + }; + + const handleSwitchToManual = () => { + setAuthMode('manual'); + setOauthUsername(null); + }; + + const handleSwitchToOAuth = () => { + setAuthMode('oauth'); + }; + + const handleSelectRepo = (repoFullName: string) => { + debugLog('Selected repo:', repoFullName); + updateEnvConfig({ githubRepo: repoFullName }); + }; return (
@@ -45,17 +135,107 @@ export function GitHubIntegration({ {envConfig.githubEnabled && ( <> - setShowGitHubToken(!showGitHubToken)} - onChange={(value) => updateEnvConfig({ githubToken: value })} - /> + {/* OAuth Success State */} + {authMode === 'oauth-success' && ( +
+
+
+
+ +
+

Connected via GitHub CLI

+ {oauthUsername && ( +

+ + Authenticated as {oauthUsername} +

+ )} +
+
+ +
+
- updateEnvConfig({ githubRepo: value })} - /> + {/* Repository Dropdown */} + setAuthMode('manual')} + /> +
+ )} + + {/* OAuth Flow */} + {authMode === 'oauth' && ( +
+
+ + +
+ +
+ )} + + {/* Manual Token Entry */} + {authMode === 'manual' && ( + <> +
+
+ + +
+

+ Create a token with repo scope from{' '} + + GitHub Settings + +

+ updateEnvConfig({ githubToken: value })} + placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx" + /> +
+ + updateEnvConfig({ githubRepo: value })} + /> + + )} {envConfig.githubToken && envConfig.githubRepo && ( void; - onChange: (value: string) => void; +interface RepositoryDropdownProps { + repos: GitHubRepo[]; + selectedRepo: string; + isLoading: boolean; + error: string | null; + onSelect: (repoFullName: string) => void; + onRefresh: () => void; + onManualEntry: () => void; } -function TokenInput({ value, showToken, onToggleVisibility, onChange }: TokenInputProps) { +function RepositoryDropdown({ + repos, + selectedRepo, + isLoading, + error, + onSelect, + onRefresh, + onManualEntry +}: RepositoryDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [filter, setFilter] = useState(''); + + const filteredRepos = repos.filter(repo => + repo.fullName.toLowerCase().includes(filter.toLowerCase()) || + (repo.description?.toLowerCase().includes(filter.toLowerCase())) + ); + + const selectedRepoData = repos.find(r => r.fullName === selectedRepo); + return (
- -

- Create a token with repo scope from{' '} - - GitHub Settings - -

+
+ +
+ + +
+
+ + {error && ( +
+ + {error} +
+ )} +
- onChange(e.target.value)} - className="pr-10" - /> + + {isOpen && !isLoading && ( +
+ {/* Search filter */} +
+ setFilter(e.target.value)} + className="h-8 text-sm" + autoFocus + /> +
+ + {/* Repository list */} +
+ {filteredRepos.length === 0 ? ( +
+ {filter ? 'No matching repositories' : 'No repositories found'} +
+ ) : ( + filteredRepos.map((repo) => ( + + )) + )} +
+
+ )}
+ + {selectedRepo && ( +

+ Selected: {selectedRepo} +

+ )}
); } diff --git a/auto-claude-ui/src/renderer/lib/mocks/integration-mock.ts b/auto-claude-ui/src/renderer/lib/mocks/integration-mock.ts index 59b5f7ca..d48b7ebb 100644 --- a/auto-claude-ui/src/renderer/lib/mocks/integration-mock.ts +++ b/auto-claude-ui/src/renderer/lib/mocks/integration-mock.ts @@ -131,5 +131,47 @@ export const integrationMock = { onGitHubInvestigationProgress: () => () => {}, onGitHubInvestigationComplete: () => () => {}, - onGitHubInvestigationError: () => () => {} + onGitHubInvestigationError: () => () => {}, + + // GitHub OAuth Operations (gh CLI) + checkGitHubCli: async () => ({ + success: true, + data: { + installed: false, + version: undefined + } + }), + + checkGitHubAuth: async () => ({ + success: true, + data: { + authenticated: false, + username: undefined + } + }), + + startGitHubAuth: async () => ({ + success: false, + error: 'Not available in browser mock' + }), + + getGitHubToken: async () => ({ + success: false, + error: 'Not available in browser mock' + }), + + getGitHubUser: async () => ({ + success: false, + error: 'Not available in browser mock' + }), + + listGitHubUserRepos: async () => ({ + success: true, + data: { + repos: [ + { fullName: 'user/example-repo', description: 'An example repository', isPrivate: false }, + { fullName: 'user/private-repo', description: 'A private repository', isPrivate: true } + ] + } + }) }; diff --git a/auto-claude-ui/src/shared/constants/ipc.ts b/auto-claude-ui/src/shared/constants/ipc.ts index 763ecd25..1e024a2f 100644 --- a/auto-claude-ui/src/shared/constants/ipc.ts +++ b/auto-claude-ui/src/shared/constants/ipc.ts @@ -171,6 +171,14 @@ export const IPC_CHANNELS = { GITHUB_IMPORT_ISSUES: 'github:importIssues', GITHUB_CREATE_RELEASE: 'github:createRelease', + // GitHub OAuth (gh CLI authentication) + GITHUB_CHECK_CLI: 'github:checkCli', + GITHUB_CHECK_AUTH: 'github:checkAuth', + GITHUB_START_AUTH: 'github:startAuth', + GITHUB_GET_TOKEN: 'github:getToken', + GITHUB_GET_USER: 'github:getUser', + GITHUB_LIST_USER_REPOS: 'github:listUserRepos', + // GitHub events (main -> renderer) GITHUB_INVESTIGATION_PROGRESS: 'github:investigationProgress', GITHUB_INVESTIGATION_COMPLETE: 'github:investigationComplete', diff --git a/auto-claude-ui/src/shared/types/ipc.ts b/auto-claude-ui/src/shared/types/ipc.ts index a4e961ce..27da7136 100644 --- a/auto-claude-ui/src/shared/types/ipc.ts +++ b/auto-claude-ui/src/shared/types/ipc.ts @@ -292,6 +292,14 @@ export interface ElectronAPI { options?: { draft?: boolean; prerelease?: boolean } ) => Promise>; + // GitHub OAuth operations (gh CLI) + checkGitHubCli: () => Promise>; + checkGitHubAuth: () => Promise>; + startGitHubAuth: () => Promise>; + getGitHubToken: () => Promise>; + getGitHubUser: () => Promise>; + listGitHubUserRepos: () => Promise }>>; + // GitHub event listeners onGitHubInvestigationProgress: ( callback: (projectId: string, status: GitHubInvestigationStatus) => void