for *.ghe.com, or /api/v3 for GHES) */ function githubApiUrlFromHtmlUrl(string $htmlUrl): string { if (isGithubDotComHost($htmlUrl)) { return 'https://api.github.com'; } if (isGheDotComHost($htmlUrl)) { return 'https://api.'.githubUrlHost($htmlUrl); } return githubUrlOrigin($htmlUrl).'/api/v3'; } /** * Normalize a GitHub organization slug by trimming surrounding slashes and whitespace. * * @param string|null $organization The raw organization value * @return string|null The trimmed organization, or null when blank */ function normalizeGithubOrganization(?string $organization): ?string { if (blank($organization)) { return null; } return trim((string) $organization, "/ \t\n\r\0\x0B"); } /** * URL-encode a single GitHub path segment. * * @param string $segment The raw path segment * @return string The raw-URL-encoded segment */ function encodeGithubPathSegment(string $segment): string { return rawurlencode($segment); } function assertGithubClockInSync(string $apiUrl): void { $response = Http::get("{$apiUrl}/zen"); $serverTime = CarbonImmutable::now()->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 $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 = encodeGithubPathSegment(Str::kebab($source->name)); $state = Str::random(64); $organization = normalizeGithubOrganization($source->organization); if (isGithubEnterpriseServerHost($source->html_url)) { $path = "github-apps/{$name}"; } elseif (isGheDotComHost($source->html_url) && filled($organization)) { $path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}"; } else { $path = "apps/{$name}"; } Cache::put('github-app-setup-state:'.hash('sha256', $state), [ 'action' => 'install', 'github_app_id' => $source->id, 'team_id' => $source->team_id, ], now()->addMinutes(60)); return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) { $name = encodeGithubPathSegment(Str::kebab($source->name)); $organization = normalizeGithubOrganization($source->organization); if (filled($organization)) { return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions"; } return rtrim($source->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 getGithubCommitMessage(?GithubApp $source, string $owner, string $repo, string $commitSha): ?string { try { if (! $source) { return null; } if (blank($owner) || blank($repo) || blank($commitSha) || $commitSha === 'HEAD') { return null; } $endpoint = "/repos/{$owner}/{$repo}/commits/{$commitSha}"; $response = githubApi($source, $endpoint, 'get', null, false); $message = data_get($response, 'data.commit.message'); return is_string($message) ? $message : null; } catch (Exception $e) { return null; } } 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 []; } }