192 lines
8.0 KiB
PHP
192 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace NetBoxStore\Sync\Adapter;
|
|
|
|
use RuntimeException;
|
|
|
|
final class GitHubAdapter extends AbstractAdapter
|
|
{
|
|
private const MAX_REPOSITORY_PAGES = 200;
|
|
private const MAX_RELEASE_PAGES = 100;
|
|
|
|
protected function authorizationValue(string $token): string
|
|
{
|
|
return 'Bearer ' . $token;
|
|
}
|
|
|
|
protected function apiHeaders(string $accept = 'application/vnd.github+json'): array
|
|
{
|
|
return array_merge(parent::apiHeaders($accept), ['X-GitHub-Api-Version: 2022-11-28']);
|
|
}
|
|
|
|
public function listRepositories(): array
|
|
{
|
|
$owner = rawurlencode((string) $this->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<array<string,mixed>>|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;
|
|
}
|
|
}
|
|
}
|