source['owner']); $kind = $this->source['ownerKind'] ?? 'user'; $endpoints = $kind === 'auto' ? ['orgs/' . $owner . '/repos', 'users/' . $owner . '/repos'] : [($kind === 'organization' ? 'orgs/' : 'users/') . $owner . '/repos']; $payload = null; foreach ($endpoints as $endpoint) { $payload = $this->paged($endpoint); if ($payload !== null && ($payload !== [] || $kind !== 'auto')) { break; } } if ($payload === null) { throw new RuntimeException('GitHub owner was not found.'); } return array_map(function (array $item): array { $repository = [ 'externalId' => (string) ($item['id'] ?? $item['full_name'] ?? ''), 'owner' => (string) ($item['owner']['login'] ?? $this->source['owner']), 'name' => (string) ($item['name'] ?? ''), 'fullName' => (string) ($item['full_name'] ?? ''), 'htmlUrl' => (string) ($item['html_url'] ?? ''), 'defaultBranch' => (string) ($item['default_branch'] ?? 'main'), 'description' => (string) ($item['description'] ?? ''), 'homepageUrl' => (string) ($item['homepage'] ?? ''), 'topics' => array_values(array_map('strval', is_array($item['topics'] ?? null) ? $item['topics'] : [])), 'archived' => (bool) ($item['archived'] ?? false), 'fork' => (bool) ($item['fork'] ?? false), 'empty' => isset($item['size']) ? (int) $item['size'] === 0 : false, 'private' => (bool) ($item['private'] ?? false), ]; $this->validateRepository($repository); return $repository; }, $payload); } public function getCommitSha(array $repository, ?string $ref = null): string { $ref ??= (string) $repository['defaultBranch']; $data = $this->apiJson('repos/' . $repository['fullName'] . '/commits/' . rawurlencode($ref), [], true); $sha = strtolower((string) ($data['sha'] ?? '')); return preg_match('/^[a-f0-9]{40,64}$/', $sha) ? $sha : ''; } public function fetchText(array $repository, string $path, string $commitSha): ?string { $this->validateRepository($repository); $this->assertCommit($commitSha); $url = $this->apiUrl . '/repos/' . $repository['fullName'] . '/contents/' . $this->encodedPath($path) . '?ref=' . rawurlencode($commitSha); return $this->http->getText($url, $this->apiHeaders('application/vnd.github.raw+json'), $this->apiOrigin, true); } public function rawFileUrl(array $repository, string $path, string $commitSha): string { $this->validateRepository($repository); $this->assertCommit($commitSha); return 'https://raw.githubusercontent.com/' . $repository['fullName'] . '/' . $commitSha . '/' . $this->encodedPath($path); } public function listReleases(array $repository): array { if (!empty($repository['private'])) { return []; } $payload = []; for ($page = 1; $page <= self::MAX_RELEASE_PAGES; $page++) { $chunk = $this->apiJson('repos/' . $repository['fullName'] . '/releases', ['per_page' => 100, 'page' => $page]); if (!is_array($chunk) || !array_is_list($chunk)) { throw new RuntimeException('GitHub release response is invalid.'); } $this->accountReleasePage($chunk); foreach ($chunk as $item) { $payload[] = $item; } if (count($chunk) < 100) { break; } if ($page === self::MAX_RELEASE_PAGES) { throw new RuntimeException('GitHub release pagination exceeded the safety limit.'); } } $releases = []; foreach ($payload as $item) { if (!is_array($item) || trim((string) ($item['tag_name'] ?? '')) === '') { continue; } $tag = trim((string) $item['tag_name']); $commit = $this->getCommitSha($repository, $tag); $assets = is_array($item['assets'] ?? null) ? $item['assets'] : []; $assets = array_values(array_filter($assets, static fn (mixed $asset): bool => is_array($asset) && self::assetRank($asset) < 99)); usort($assets, static fn (array $a, array $b): int => (self::assetRank($a) <=> self::assetRank($b)) ?: strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''))); $asset = $assets[0] ?? []; if ($asset === []) { continue; } $digest = preg_replace('/^sha256:/i', '', (string) ($asset['digest'] ?? '')) ?? ''; $releases[] = [ 'externalId' => (string) ($item['id'] ?? 'tag:' . $tag), 'version' => $this->normalizeVersion($tag), 'title' => (string) ($item['name'] ?? $tag), 'releaseUrl' => (string) ($item['html_url'] ?? ''), 'downloadUrl' => (string) ($asset['browser_download_url'] ?? ''), 'expectedSha256' => preg_match('/^[a-f0-9]{64}$/i', $digest) ? strtolower($digest) : '', 'commitSha' => $commit, 'prerelease' => (bool) ($item['prerelease'] ?? false), 'draft' => (bool) ($item['draft'] ?? false), 'changelog' => (string) ($item['body'] ?? ''), 'publishedAt' => self::date((string) ($item['published_at'] ?? $item['created_at'] ?? '')), ]; } return $releases; } public function sourceArchiveUrl(array $repository, string $commitSha): ?string { if (!empty($repository['private'])) { return null; } $this->validateRepository($repository); $this->assertCommit($commitSha); return 'https://codeload.github.com/' . $repository['fullName'] . '/tar.gz/' . $commitSha; } /** @return list>|null */ private function paged(string $endpoint): ?array { $repositories = []; for ($page = 1; $page <= self::MAX_REPOSITORY_PAGES; $page++) { $data = $this->apiJson($endpoint, ['page' => $page, 'per_page' => 50, 'type' => 'owner'], true); if ($data === null) { return null; } if (!array_is_list($data)) { throw new RuntimeException('GitHub repository response is invalid.'); } $this->accountRepositoryPage($data); foreach ($data as $item) { $repositories[] = $item; } if (count($data) < 50) { return $repositories; } } throw new RuntimeException('GitHub repository pagination exceeded the 10,000 repository safety limit.'); } private static function assetRank(array $asset): int { $name = strtolower((string) ($asset['name'] ?? '')); if (!str_ends_with($name, '.whl')) { return 99; } return str_ends_with($name, 'py3-none-any.whl') ? 0 : 1; } private static function date(string $value): ?string { try { return $value === '' ? null : (new \DateTimeImmutable($value))->format(DATE_ATOM); } catch (\Throwable) { return null; } } }