feat: add NetBox plugin store
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 20:51:25 +02:00
commit f36d6be511
135 changed files with 15160 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
<?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;
}
/** @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;
}
}
}