New github oauth integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+60
-20
@@ -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);
|
||||
|
||||
+328
-41
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user