New github oauth integration

This commit is contained in:
AndyMik90
2025-12-16 22:53:18 +01:00
parent 6aea4bb830
commit afeb54fc2c
13 changed files with 1186 additions and 71 deletions
@@ -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 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
@@ -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
@@ -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<IPCResult<{ installed: boolean; version?: string }>> => {
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<IPCResult<{ authenticated: boolean; username?: string }>> => {
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<IPCResult<{ success: boolean; message?: string }>> => {
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<IPCResult<{ token: string }>> => {
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<IPCResult<{ username: string; name?: string }>> => {
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<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> => {
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');
}
@@ -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 {
@@ -28,6 +28,14 @@ export interface GitHubAPI {
options?: { draft?: boolean; prerelease?: boolean }
) => Promise<IPCResult<{ url: string }>>;
// OAuth operations (gh CLI)
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
// Event Listeners
onGitHubInvestigationProgress: (
callback: (projectId: string, status: GitHubInvestigationStatus) => void
@@ -71,6 +79,25 @@ export const createGitHubAPI = (): GitHubAPI => ({
): Promise<IPCResult<{ url: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options),
// OAuth operations (gh CLI)
checkGitHubCli: (): Promise<IPCResult<{ installed: boolean; version?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CLI),
checkGitHubAuth: (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_AUTH),
startGitHubAuth: (): Promise<IPCResult<{ success: boolean; message?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_START_AUTH),
getGitHubToken: (): Promise<IPCResult<{ token: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_TOKEN),
getGitHubUser: (): Promise<IPCResult<{ username: string; name?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_USER),
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
// Event Listeners
onGitHubInvestigationProgress: (
callback: (projectId: string, status: GitHubInvestigationStatus) => void
+9 -2
View File
@@ -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<AppSection | undefined>(undefined);
const [settingsInitialProjectSection, setSettingsInitialProjectSection] = useState<ProjectSettingsSection | undefined>(undefined);
const [activeView, setActiveView] = useState<SidebarView>('kanban');
const [isOnboardingWizardOpen, setIsOnboardingWizardOpen] = useState(false);
@@ -319,7 +321,10 @@ export function App() {
<Insights projectId={selectedProjectId} />
)}
{activeView === 'github-issues' && selectedProjectId && (
<GitHubIssues onOpenSettings={() => setIsSettingsDialogOpen(true)} />
<GitHubIssues onOpenSettings={() => {
setSettingsInitialProjectSection('github');
setIsSettingsDialogOpen(true);
}} />
)}
{activeView === 'changelog' && selectedProjectId && (
<Changelog />
@@ -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 });
@@ -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 ? (
<StatusBadge status="success" label="Enabled" />
) : null;
const handleOAuthSuccess = (token: string, _username?: string) => {
onUpdateConfig({ githubToken: token });
setShowOAuthFlow(false);
};
return (
<CollapsibleSection
title="GitHub Integration"
@@ -53,25 +63,55 @@ export function GitHubIntegrationSection({
{envConfig.githubEnabled && (
<>
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Personal Access Token</Label>
<p className="text-xs text-muted-foreground">
Create a token with <code className="px-1 bg-muted rounded">repo</code> scope from{' '}
<a
href="https://github.com/settings/tokens/new?scopes=repo&description=Auto-Build-UI"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
GitHub Settings
</a>
</p>
<PasswordInput
value={envConfig.githubToken || ''}
onChange={(value) => onUpdateConfig({ githubToken: value })}
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
/>
</div>
{showOAuthFlow ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowOAuthFlow(false)}
>
Use Manual Token
</Button>
</div>
<GitHubOAuthFlow
onSuccess={handleOAuthSuccess}
onCancel={() => setShowOAuthFlow(false)}
/>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Personal Access Token</Label>
<Button
variant="outline"
size="sm"
onClick={() => setShowOAuthFlow(true)}
className="gap-2"
>
<KeyRound className="h-3 w-3" />
Use OAuth Instead
</Button>
</div>
<p className="text-xs text-muted-foreground">
Create a token with <code className="px-1 bg-muted rounded">repo</code> scope from{' '}
<a
href="https://github.com/settings/tokens/new?scopes=repo&description=Auto-Build-UI"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
GitHub Settings
</a>
</p>
<PasswordInput
value={envConfig.githubToken || ''}
onChange={(value) => onUpdateConfig({ githubToken: value })}
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
/>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Repository</Label>
@@ -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<string | null>(null);
const [cliInstalled, setCliInstalled] = useState(false);
const [cliVersion, setCliVersion] = useState<string | undefined>();
const [username, setUsername] = useState<string | undefined>();
// 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 (
<div className="space-y-4">
{/* Checking status */}
{status === 'checking' && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Need to install gh CLI */}
{status === 'need-install' && (
<div className="space-y-4">
<Card className="border border-warning/30 bg-warning/10">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Terminal className="h-6 w-6 text-warning shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-medium text-foreground">
GitHub CLI Required
</h3>
<p className="text-sm text-muted-foreground">
The GitHub CLI (gh) is required for OAuth authentication. This provides a secure
way to authenticate without manually creating tokens.
</p>
<div className="flex gap-3">
<Button onClick={handleOpenGhInstall} className="gap-2">
<ExternalLink className="h-4 w-4" />
Install GitHub CLI
</Button>
<Button variant="outline" onClick={handleRetry}>
I've Installed It
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-2">Installation instructions:</p>
<ul className="space-y-1 list-disc list-inside">
<li>macOS: <code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">brew install gh</code></li>
<li>Windows: <code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">winget install GitHub.cli</code></li>
<li>Linux: Visit <a href="https://cli.github.com/" target="_blank" rel="noopener noreferrer" className="text-info hover:underline">cli.github.com</a></li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Need authentication */}
{status === 'need-auth' && (
<div className="space-y-4">
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Github className="h-6 w-6 text-info shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-medium text-foreground">
Connect to GitHub
</h3>
<p className="text-sm text-muted-foreground">
Click the button below to authenticate with GitHub. This will open your browser
where you can authorize the application.
</p>
{cliVersion && (
<p className="text-xs text-muted-foreground">
Using GitHub CLI {cliVersion}
</p>
)}
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-center">
<Button onClick={handleStartAuth} size="lg" className="gap-2">
<Github className="h-5 w-5" />
Authenticate with GitHub
</Button>
</div>
</div>
)}
{/* Authenticating */}
{status === 'authenticating' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
Please complete the authentication in your browser. This window will update automatically.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Success */}
{status === 'success' && (
<Card className="border border-success/30 bg-success/10">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="h-6 w-6 text-success shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-medium text-success">
Successfully Connected
</h3>
<p className="text-sm text-success/80 mt-1">
{username ? `Connected as ${username}` : 'Your GitHub account is now connected'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Error */}
{status === 'error' && error && (
<div className="space-y-4">
<Card className="border border-destructive/30 bg-destructive/10">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-medium text-destructive">
Authentication Failed
</h3>
<p className="text-sm text-destructive/80 mt-1">{error}</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-center gap-3">
<Button onClick={handleRetry} variant="outline">
Retry
</Button>
{onCancel && (
<Button onClick={onCancel} variant="ghost">
Cancel
</Button>
)}
</div>
</div>
)}
{/* Cancel button for non-error states */}
{status !== 'error' && status !== 'success' && onCancel && (
<div className="flex justify-center pt-2">
<Button onClick={onCancel} variant="ghost">
Cancel
</Button>
</div>
)}
</div>
);
}
@@ -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<ProjectSettingsSection>[] = [
* 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<string>('');
@@ -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);
@@ -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<ProjectEnvConfig>) => 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<string | null>(null);
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [reposError, setReposError] = useState<string | null>(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 (
<div className="space-y-4">
@@ -45,17 +135,107 @@ export function GitHubIntegration({
{envConfig.githubEnabled && (
<>
<TokenInput
value={envConfig.githubToken || ''}
showToken={showGitHubToken}
onToggleVisibility={() => setShowGitHubToken(!showGitHubToken)}
onChange={(value) => updateEnvConfig({ githubToken: value })}
/>
{/* OAuth Success State */}
{authMode === 'oauth-success' && (
<div className="space-y-4">
<div className="rounded-lg border border-success/30 bg-success/10 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-success" />
<div>
<p className="text-sm font-medium text-success">Connected via GitHub CLI</p>
{oauthUsername && (
<p className="text-xs text-success/80 flex items-center gap-1 mt-0.5">
<User className="h-3 w-3" />
Authenticated as {oauthUsername}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleSwitchToManual}
className="text-xs"
>
Use Different Token
</Button>
</div>
</div>
<RepositoryInput
value={envConfig.githubRepo || ''}
onChange={(value) => updateEnvConfig({ githubRepo: value })}
/>
{/* Repository Dropdown */}
<RepositoryDropdown
repos={repos}
selectedRepo={envConfig.githubRepo || ''}
isLoading={isLoadingRepos}
error={reposError}
onSelect={handleSelectRepo}
onRefresh={fetchUserRepos}
onManualEntry={() => setAuthMode('manual')}
/>
</div>
)}
{/* OAuth Flow */}
{authMode === 'oauth' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
<Button
variant="ghost"
size="sm"
onClick={handleSwitchToManual}
>
Use Manual Token
</Button>
</div>
<GitHubOAuthFlow
onSuccess={handleOAuthSuccess}
onCancel={handleSwitchToManual}
/>
</div>
)}
{/* Manual Token Entry */}
{authMode === 'manual' && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Personal Access Token</Label>
<Button
variant="outline"
size="sm"
onClick={handleSwitchToOAuth}
className="gap-2"
>
<KeyRound className="h-3 w-3" />
Use OAuth Instead
</Button>
</div>
<p className="text-xs text-muted-foreground">
Create a token with <code className="px-1 bg-muted rounded">repo</code> scope from{' '}
<a
href="https://github.com/settings/tokens/new?scopes=repo&description=Auto-Build-UI"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
GitHub Settings
</a>
</p>
<PasswordInput
value={envConfig.githubToken || ''}
onChange={(value) => updateEnvConfig({ githubToken: value })}
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
/>
</div>
<RepositoryInput
value={envConfig.githubRepo || ''}
onChange={(value) => updateEnvConfig({ githubRepo: value })}
/>
</>
)}
{envConfig.githubToken && envConfig.githubRepo && (
<ConnectionStatus
@@ -78,44 +258,151 @@ export function GitHubIntegration({
);
}
interface TokenInputProps {
value: string;
showToken: boolean;
onToggleVisibility: () => 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 (
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Personal Access Token</Label>
<p className="text-xs text-muted-foreground">
Create a token with <code className="px-1 bg-muted rounded">repo</code> scope from{' '}
<a
href="https://github.com/settings/tokens/new?scopes=repo&description=Auto-Build-UI"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
GitHub Settings
</a>
</p>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Repository</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onManualEntry}
className="h-7 text-xs"
>
Enter Manually
</Button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<div className="relative">
<Input
type={showToken ? 'text' : 'password'}
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
value={value}
onChange={(e) => onChange(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={onToggleVisibility}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{isLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading repositories...
</span>
) : selectedRepo ? (
<span className="flex items-center gap-2">
{selectedRepoData?.isPrivate ? (
<Lock className="h-3 w-3 text-muted-foreground" />
) : (
<Globe className="h-3 w-3 text-muted-foreground" />
)}
{selectedRepo}
</span>
) : (
<span className="text-muted-foreground">Select a repository...</span>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isLoading && (
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
{/* Search filter */}
<div className="p-2 border-b border-border">
<Input
placeholder="Search repositories..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
</div>
{/* Repository list */}
<div className="max-h-48 overflow-y-auto">
{filteredRepos.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
{filter ? 'No matching repositories' : 'No repositories found'}
</div>
) : (
filteredRepos.map((repo) => (
<button
key={repo.fullName}
type="button"
onClick={() => {
onSelect(repo.fullName);
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-start gap-2 ${
repo.fullName === selectedRepo ? 'bg-accent' : ''
}`}
>
{repo.isPrivate ? (
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
) : (
<Globe className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{repo.fullName}</p>
{repo.description && (
<p className="text-xs text-muted-foreground truncate">{repo.description}</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
{selectedRepo && (
<p className="text-xs text-muted-foreground">
Selected: <code className="px-1 bg-muted rounded">{selectedRepo}</code>
</p>
)}
</div>
);
}
@@ -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 }
]
}
})
};
@@ -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',
+8
View File
@@ -292,6 +292,14 @@ export interface ElectronAPI {
options?: { draft?: boolean; prerelease?: boolean }
) => Promise<IPCResult<{ url: string }>>;
// GitHub OAuth operations (gh CLI)
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
// GitHub event listeners
onGitHubInvestigationProgress: (
callback: (projectId: string, status: GitHubInvestigationStatus) => void