diff --git a/app/Http/Controllers/GitHubAppController.php b/app/Http/Controllers/GitHubAppController.php new file mode 100644 index 0000000..3257704 --- /dev/null +++ b/app/Http/Controllers/GitHubAppController.php @@ -0,0 +1,129 @@ +route('dashboard') + ->with('success', 'GitHub App connected!'); + } + + /** + * Start device flow authentication. + * Returns a user code to enter at github.com/login/device + */ + public function device(): JsonResponse + { + $deviceCode = $this->gitHubApp->startDeviceFlow(); + + return response()->json([ + 'device_code' => $deviceCode['device_code'], + 'user_code' => $deviceCode['user_code'], + 'verification_uri' => $deviceCode['verification_uri'], + 'expires_in' => $deviceCode['expires_in'], + 'interval' => $deviceCode['interval'], + ]); + } + + /** + * Poll for device flow completion. + */ + public function poll(Request $request): JsonResponse + { + $request->validate([ + 'device_code' => 'required|string', + ]); + + $result = $this->gitHubApp->pollDeviceFlow($request->device_code); + + return response()->json($result); + } + + /** + * OAuth callback (for web flow, if needed later). + */ + public function callback(Request $request): RedirectResponse + { + $code = $request->query('code'); + $installationId = $request->query('installation_id'); + $setupAction = $request->query('setup_action'); + + if ($installationId) { + $this->gitHubApp->handleInstallation($installationId); + } + + if ($code) { + $this->gitHubApp->exchangeCodeForToken($code); + } + + return redirect()->route('github.index') + ->with('success', 'GitHub App configured successfully!'); + } + + /** + * Webhook endpoint (placeholder - needs tunnel for real use). + */ + public function webhook(Request $request): JsonResponse + { + $event = $request->header('X-GitHub-Event'); + $payload = $request->all(); + + // Log for now, process later when we have tunnel + logger()->info('GitHub webhook received', [ + 'event' => $event, + 'action' => $payload['action'] ?? null, + ]); + + return response()->json(['status' => 'received']); + } + + /** + * Test making a commit as the app. + */ + public function testCommit(Request $request): JsonResponse + { + $request->validate([ + 'installation_id' => 'required|integer', + 'repo' => 'required|string', + ]); + + $result = $this->gitHubApp->testCommitAsApp( + $request->installation_id, + $request->repo + ); + + return response()->json($result); + } + + /** + * Get installation token for API calls. + */ + public function installationToken(Request $request): JsonResponse + { + $request->validate([ + 'installation_id' => 'required|integer', + ]); + + $token = $this->gitHubApp->getInstallationToken($request->installation_id); + + return response()->json([ + 'token' => $token['token'], + 'expires_at' => $token['expires_at'], + ]); + } +} diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php new file mode 100644 index 0000000..b0c7558 --- /dev/null +++ b/app/Services/GitHubAppService.php @@ -0,0 +1,421 @@ +appId = config('services.github.app_id', ''); + $this->clientId = config('services.github.client_id', ''); + $this->clientSecret = config('services.github.client_secret', ''); + $this->privateKey = $this->loadPrivateKey(); + } + + private function loadPrivateKey(): ?string + { + // Try direct key first + $key = config('services.github.private_key'); + if ($key) { + return $key; + } + + // Try file path + $path = config('services.github.private_key_path'); + if ($path && file_exists(base_path($path))) { + return file_get_contents(base_path($path)); + } + + return null; + } + + /** + * Check if the GitHub App is configured. + */ + public function isInstalled(): bool + { + return ! empty($this->appId) && ! empty($this->privateKey); + } + + /** + * Get all installations of this app. + * + * @return array> + */ + public function getInstallations(): array + { + if (! $this->isInstalled()) { + return []; + } + + $jwt = $this->generateJwt(); + + $response = Http::withToken($jwt, 'Bearer') + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/app/installations"); + + if ($response->successful()) { + return $response->json(); + } + + return []; + } + + /** + * Start device flow for user authentication. + * + * @return array + */ + public function startDeviceFlow(): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/device/code', [ + 'client_id' => $this->clientId, + 'scope' => 'repo read:org', + ]); + + return $response->json(); + } + + /** + * Poll for device flow completion. + * + * @return array + */ + public function pollDeviceFlow(string $deviceCode): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => $this->clientId, + 'device_code' => $deviceCode, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + ]); + + return $response->json(); + } + + /** + * Exchange authorization code for access token. + * + * @return array + */ + public function exchangeCodeForToken(string $code): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $code, + ]); + + $data = $response->json(); + + if (isset($data['access_token'])) { + Cache::put('github_user_token', $data['access_token'], now()->addSeconds($data['expires_in'] ?? 28800)); + } + + return $data; + } + + /** + * Handle new installation. + */ + public function handleInstallation(int $installationId): void + { + Cache::put('github_installation_id', $installationId); + } + + /** + * Get installation access token. + * + * @return array + */ + public function getInstallationToken(int $installationId): array + { + $cacheKey = "github_installation_token_{$installationId}"; + + return Cache::remember($cacheKey, now()->addMinutes(55), function () use ($installationId) { + $jwt = $this->generateJwt(); + + $response = Http::withToken($jwt, 'Bearer') + ->accept('application/vnd.github+json') + ->post("{$this->baseUrl}/app/installations/{$installationId}/access_tokens"); + + return $response->json(); + }); + } + + /** + * Test making a commit as the GitHub App. + * + * @return array + */ + public function testCommitAsApp(int $installationId, string $repo): array + { + $token = $this->getInstallationToken($installationId); + + if (! isset($token['token'])) { + return ['error' => 'Failed to get installation token']; + } + + // Get the default branch + $repoResponse = Http::withToken($token['token']) + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/repos/{$repo}"); + + if (! $repoResponse->successful()) { + return ['error' => 'Failed to get repo info']; + } + + $defaultBranch = $repoResponse->json('default_branch'); + + // Get current commit SHA + $refResponse = Http::withToken($token['token']) + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/repos/{$repo}/git/ref/heads/{$defaultBranch}"); + + if (! $refResponse->successful()) { + return ['error' => 'Failed to get ref']; + } + + return [ + 'success' => true, + 'repo' => $repo, + 'branch' => $defaultBranch, + 'sha' => $refResponse->json('object.sha'), + 'token_expires' => $token['expires_at'], + 'message' => 'Ready to commit as the-shit-agents app!', + ]; + } + + /** + * Generate JWT for GitHub App authentication. + */ + private function generateJwt(): string + { + $now = time(); + + $payload = [ + 'iat' => $now - 60, + 'exp' => $now + (10 * 60), + 'iss' => $this->appId, + ]; + + return JWT::encode($payload, $this->privateKey, 'RS256'); + } + + /** + * Make an authenticated API request as the app. + * + * @param array $data + * @return array + */ + public function apiRequest(int $installationId, string $method, string $endpoint, array $data = []): array + { + $token = $this->getInstallationToken($installationId); + + if (! isset($token['token'])) { + return ['error' => 'Failed to get installation token']; + } + + $request = Http::withToken($token['token']) + ->accept('application/vnd.github+json'); + + $response = match (strtoupper($method)) { + 'GET' => $request->get("{$this->baseUrl}{$endpoint}"), + 'POST' => $request->post("{$this->baseUrl}{$endpoint}", $data), + 'PUT' => $request->put("{$this->baseUrl}{$endpoint}", $data), + 'PATCH' => $request->patch("{$this->baseUrl}{$endpoint}", $data), + 'DELETE' => $request->delete("{$this->baseUrl}{$endpoint}"), + default => throw new \InvalidArgumentException("Unsupported method: {$method}"), + }; + + return $response->json() ?? []; + } + + /** + * Create a commit as the GitHub App with optional co-author. + * + * @param array $files Array of path => content + * @return array + */ + public function createCommit( + int $installationId, + string $repo, + string $branch, + string $message, + array $files, + ?string $coAuthor = null + ): array { + // Add co-author to message if provided + if ($coAuthor) { + $message .= "\n\nCo-Authored-By: {$coAuthor}"; + } + + // Get the current commit SHA for the branch + $ref = $this->apiRequest($installationId, 'GET', "/repos/{$repo}/git/ref/heads/{$branch}"); + if (isset($ref['error'])) { + return $ref; + } + $baseSha = $ref['object']['sha']; + + // Get the base tree + $baseCommit = $this->apiRequest($installationId, 'GET', "/repos/{$repo}/git/commits/{$baseSha}"); + if (isset($baseCommit['error'])) { + return $baseCommit; + } + $baseTreeSha = $baseCommit['tree']['sha']; + + // Create blobs for each file + $treeItems = []; + foreach ($files as $path => $content) { + $blob = $this->apiRequest($installationId, 'POST', "/repos/{$repo}/git/blobs", [ + 'content' => $content, + 'encoding' => 'utf-8', + ]); + if (isset($blob['error'])) { + return $blob; + } + + $treeItems[] = [ + 'path' => $path, + 'mode' => '100644', + 'type' => 'blob', + 'sha' => $blob['sha'], + ]; + } + + // Create new tree + $tree = $this->apiRequest($installationId, 'POST', "/repos/{$repo}/git/trees", [ + 'base_tree' => $baseTreeSha, + 'tree' => $treeItems, + ]); + if (isset($tree['error'])) { + return $tree; + } + + // Create commit + $commit = $this->apiRequest($installationId, 'POST', "/repos/{$repo}/git/commits", [ + 'message' => $message, + 'tree' => $tree['sha'], + 'parents' => [$baseSha], + ]); + if (isset($commit['error'])) { + return $commit; + } + + // Update branch reference + $result = $this->apiRequest($installationId, 'PATCH', "/repos/{$repo}/git/refs/heads/{$branch}", [ + 'sha' => $commit['sha'], + ]); + + return [ + 'success' => true, + 'commit_sha' => $commit['sha'], + 'commit_url' => $commit['html_url'] ?? "https://github.com/{$repo}/commit/{$commit['sha']}", + 'message' => $message, + ]; + } + + /** + * Create a new branch from a base branch. + * + * @return array + */ + public function createBranch(int $installationId, string $repo, string $newBranch, string $baseBranch = 'master'): array + { + // Get base branch SHA + $ref = $this->apiRequest($installationId, 'GET', "/repos/{$repo}/git/ref/heads/{$baseBranch}"); + if (isset($ref['error'])) { + return $ref; + } + + // Create new branch + return $this->apiRequest($installationId, 'POST', "/repos/{$repo}/git/refs", [ + 'ref' => "refs/heads/{$newBranch}", + 'sha' => $ref['object']['sha'], + ]); + } + + /** + * Create a pull request as the GitHub App. + * + * @return array + */ + public function createPullRequest( + int $installationId, + string $repo, + string $head, + string $base, + string $title, + string $body + ): array { + return $this->apiRequest($installationId, 'POST', "/repos/{$repo}/pulls", [ + 'title' => $title, + 'body' => $body, + 'head' => $head, + 'base' => $base, + ]); + } + + /** + * Full workflow: create branch, commit files, create PR as the app. + * + * @param array $files + * @return array + */ + public function createPullRequestWithChanges( + int $installationId, + string $repo, + string $branchName, + string $baseBranch, + string $commitMessage, + array $files, + string $prTitle, + string $prBody, + ?string $coAuthor = null + ): array { + // Create branch + $branch = $this->createBranch($installationId, $repo, $branchName, $baseBranch); + if (isset($branch['error'])) { + return ['error' => 'Failed to create branch', 'details' => $branch]; + } + + // Create commit + $commit = $this->createCommit($installationId, $repo, $branchName, $commitMessage, $files, $coAuthor); + if (isset($commit['error'])) { + return ['error' => 'Failed to create commit', 'details' => $commit]; + } + + // Create PR + $pr = $this->createPullRequest($installationId, $repo, $branchName, $baseBranch, $prTitle, $prBody); + if (isset($pr['error'])) { + return ['error' => 'Failed to create PR', 'details' => $pr]; + } + + return [ + 'success' => true, + 'branch' => $branchName, + 'commit' => $commit, + 'pr_number' => $pr['number'], + 'pr_url' => $pr['html_url'], + ]; + } +} diff --git a/composer.json b/composer.json index b65f5f1..ab8a204 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^6.11", "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", diff --git a/composer.lock b/composer.lock index 5edc24f..7a3bab5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9cc8e4481dfdf605fadf5f2e2e48131c", + "content-hash": "4d5e136d3cc73cf3c2b23d1db52a2d68", "packages": [ { "name": "bacon/bacon-qr-code", @@ -613,6 +613,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", diff --git a/config/services.php b/config/services.php index b8ec7e2..d545553 100644 --- a/config/services.php +++ b/config/services.php @@ -44,4 +44,12 @@ 'timeout' => env('OLLAMA_TIMEOUT', 5), ], + 'github' => [ + 'app_id' => env('GITHUB_APP_ID'), + 'client_id' => env('GITHUB_CLIENT_ID'), + 'client_secret' => env('GITHUB_CLIENT_SECRET'), + 'private_key' => env('GITHUB_PRIVATE_KEY'), + 'private_key_path' => env('GITHUB_PRIVATE_KEY_PATH'), + ], + ]; diff --git a/routes/web.php b/routes/web.php index a789762..8fca59f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\ArtifactController; use App\Http\Controllers\ChatController; use App\Http\Controllers\ChatStreamController; +use App\Http\Controllers\GitHubAppController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -39,6 +40,17 @@ Route::get('artifacts/{artifact}/render', [ArtifactController::class, 'render'])->name('artifacts.render'); }); +// GitHub App routes +Route::prefix('github')->name('github.')->group(function () { + Route::get('/', [GitHubAppController::class, 'index'])->name('index')->middleware(['auth']); + Route::post('/device', [GitHubAppController::class, 'device'])->name('device')->middleware(['auth']); + Route::post('/poll', [GitHubAppController::class, 'poll'])->name('poll')->middleware(['auth']); + Route::get('/callback', [GitHubAppController::class, 'callback'])->name('callback'); + Route::post('/webhook', [GitHubAppController::class, 'webhook'])->name('webhook'); + Route::post('/test-commit', [GitHubAppController::class, 'testCommit'])->name('test-commit')->middleware(['auth']); + Route::post('/installation-token', [GitHubAppController::class, 'installationToken'])->name('installation-token')->middleware(['auth']); +}); + require __DIR__.'/settings.php'; // Dev-only auto-login route