setTimezone('UTC'); $githubTime = Carbon::parse($response->header('date')); $timeDiff = abs($serverTime->diffInSeconds($githubTime)); if ($timeDiff > 50) { throw new Exception( 'System time is out of sync with GitHub API time:
'. '- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC
'. '- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC
'. '- Difference: '.$timeDiff.' seconds
'. 'Please synchronize your system clock.' ); } } function generateGithubToken(GithubApp $source, string $type) { assertGithubClockInSync($source->api_url); $signingKey = InMemory::plainText($source->privateKey->private_key); $algorithm = new Sha256; $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); $now = CarbonImmutable::now()->setTimezone('UTC'); $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); $jwt = $tokenBuilder ->issuedBy($source->app_id) ->issuedAt($now->modify('-1 minute')) ->expiresAt($now->modify('+8 minutes')) ->getToken($algorithm, $signingKey) ->toString(); return match ($type) { 'jwt' => $jwt, 'installation' => (function () use ($source, $jwt) { $response = Http::withHeaders([ 'Authorization' => "Bearer $jwt", 'Accept' => 'application/vnd.github.machine-man-preview+json', ])->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); if (! $response->successful()) { $error = data_get($response->json(), 'message', 'no error message found'); if ($error === 'Not Found') { $error = 'Repository not found. Is it moved or deleted?'; } throw new RuntimeException("Failed to get installation token for {$source->name} with error: ".$error); } return $response->json()['token']; })(), default => throw new InvalidArgumentException("Unsupported token type: {$type}") }; } function generateGithubInstallationToken(GithubApp $source) { return generateGithubToken($source, 'installation'); } function generateGithubJwt(GithubApp $source) { return generateGithubToken($source, 'jwt'); } function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true) { if (is_null($source)) { throw new Exception('Source is required for API calls'); } if ($source->getMorphClass() !== GithubApp::class) { throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}"); } if ($source->is_public) { $response = Http::GitHub($source->api_url)->$method($endpoint); } else { $token = generateGithubInstallationToken($source); if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { $response = Http::GitHub($source->api_url, $token)->$method($endpoint, $data); } else { $response = Http::GitHub($source->api_url, $token)->$method($endpoint); } } if (! $response->successful() && $throwError) { $resetTime = Carbon::parse((int) $response->header('X-RateLimit-Reset'))->format('Y-m-d H:i:s'); $errorMessage = data_get($response->json(), 'message', 'no error message found'); $remainingCalls = $response->header('X-RateLimit-Remaining', '0'); throw new Exception( 'GitHub API call failed:
'. "Error: {$errorMessage}
". 'Rate Limit Status:
'. "- Remaining Calls: {$remainingCalls}
". "- Reset Time: {$resetTime} UTC" ); } return [ 'rate_limit_remaining' => $response->header('X-RateLimit-Remaining'), 'rate_limit_reset' => $response->header('X-RateLimit-Reset'), 'data' => collect($response->json()), ]; } function generateGithubAppJwt(string $privateKey, string|int $appId): string { $algorithm = new Sha256; $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); $now = CarbonImmutable::now()->setTimezone('UTC'); $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); return $tokenBuilder ->issuedBy((string) $appId) ->issuedAt($now->modify('-1 minute')) ->expiresAt($now->modify('+8 minutes')) ->getToken($algorithm, InMemory::plainText($privateKey)) ->toString(); } function syncGithubAppName(GithubApp $source, bool $throw = false): ?string { try { if (blank($source->app_id) || blank($source->private_key_id)) { return null; } $privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id); if (! $privateKey) { return null; } assertGithubClockInSync($source->api_url); $jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id); $response = Http::withHeaders([ 'Accept' => 'application/vnd.github+json', 'X-GitHub-Api-Version' => '2022-11-28', 'Authorization' => "Bearer {$jwt}", ])->get("{$source->api_url}/app"); if (! $response->successful()) { throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.')); } $appSlug = data_get($response->json(), 'slug'); if (blank($appSlug)) { return null; } $source->name = $appSlug; if ($source->exists) { $source->save(); } $privateKey->name = "github-app-{$appSlug}"; $privateKey->save(); return $appSlug; } catch (Throwable $e) { if ($throw) { throw $e; } return null; } } function getInstallationPath(GithubApp $source): string { $name = str(Str::kebab($source->name)); $baseUrl = rtrim($source->html_url, '/'); $host = parse_url($source->html_url, PHP_URL_HOST); $host = blank($host) ? null : Str::lower($host); $usesDataResidencyPath = filled($host) && Str::endsWith($host, '.ghe.com'); $installation_path = $host === 'github.com' || $usesDataResidencyPath ? 'apps' : 'github-apps'; $state = Str::random(64); Cache::put('github-app-setup-state:'.hash('sha256', $state), [ 'action' => 'install', 'github_app_id' => $source->id, 'team_id' => $source->team_id, ], now()->addMinutes(60)); if ($usesDataResidencyPath) { $organization = str($source->organization)->trim('/'); if ($organization->isNotEmpty()) { $organization = rawurlencode((string) $organization); return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]); } } return "$baseUrl/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) { $github = GithubApp::where('uuid', $source->uuid)->first(); $name = str(Str::kebab($github->name)); return "$github->html_url/settings/apps/$name/permissions"; } function loadRepositoryByPage(GithubApp $source, string $token, int $page) { $response = Http::GitHub($source->api_url, $token) ->timeout(20) ->retry(3, 200, throw: false) ->get('/installation/repositories', [ 'per_page' => 100, 'page' => $page, ]); $json = $response->json(); if ($response->status() !== 200) { return [ 'total_count' => 0, 'repositories' => [], ]; } if ($json['total_count'] === 0) { return [ 'total_count' => 0, 'repositories' => [], ]; } return [ 'total_count' => $json['total_count'], 'repositories' => $json['repositories'], ]; } function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $repo, string $beforeSha, string $afterSha): array { try { if (! $source) { // Manual webhooks don't have GitHub App authentication // Return empty array so watch paths are ignored (current behavior) return []; } $endpoint = "/repos/{$owner}/{$repo}/compare/{$beforeSha}...{$afterSha}"; $response = githubApi($source, $endpoint, 'get', null, false); if (! $response) { return []; } $files = collect(data_get($response, 'data.files', [])); return $files->pluck('filename')->filter()->values()->toArray(); } catch (Exception $e) { return []; } } function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): array { try { if (! $source) { // Manual webhooks don't have GitHub App authentication // Return empty array so watch paths are ignored (current behavior) return []; } $endpoint = "/repos/{$owner}/{$repo}/pulls/{$pullRequestId}/files"; $response = githubApi($source, $endpoint, 'get', null, false); if (! $response) { return []; } $files = collect(data_get($response, 'data', [])); return $files->pluck('filename')->filter()->values()->toArray(); } catch (Exception $e) { return []; } }