Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
8.2 KiB
PHP
249 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Git;
|
|
|
|
use App\Services\Git\Contracts\RepositoryProvider;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
/**
|
|
* Reads repositories from the GitHub REST API.
|
|
*
|
|
* One sync costs four calls: the repository itself, its open issues, its
|
|
* language breakdown, and its contributor count. Everything after the first is
|
|
* best-effort — a repository that answers but whose contributor list is
|
|
* unavailable still produces a usable snapshot rather than failing outright.
|
|
*/
|
|
class GitHubProvider implements RepositoryProvider
|
|
{
|
|
public function __construct(
|
|
protected string $apiUrl,
|
|
protected ?string $token = null,
|
|
protected ?string $defaultOwner = null,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public static function fromConfig(array $config): self
|
|
{
|
|
return new self(
|
|
apiUrl: rtrim((string) ($config['api_url'] ?? 'https://api.github.com'), '/'),
|
|
token: $config['token'] ?? null,
|
|
defaultOwner: $config['default_owner'] ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GitHub is usable without a token, just heavily rate limited, so this
|
|
* reports readiness rather than the presence of credentials.
|
|
*/
|
|
public function isConfigured(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function fetch(string $fullName): RepositorySnapshot
|
|
{
|
|
$fullName = $this->qualify($fullName);
|
|
|
|
$repo = $this->get("/repos/{$fullName}");
|
|
|
|
if ($repo->status() === 404) {
|
|
throw GitException::notFound($fullName);
|
|
}
|
|
|
|
$this->assertNotRateLimited($repo);
|
|
$this->assertSuccessful($repo, $fullName);
|
|
|
|
$data = (array) $repo->json();
|
|
|
|
return new RepositorySnapshot(
|
|
fullName: (string) ($data['full_name'] ?? $fullName),
|
|
htmlUrl: $data['html_url'] ?? null,
|
|
description: $data['description'] ?? null,
|
|
homepage: filled($data['homepage'] ?? null) ? $data['homepage'] : null,
|
|
defaultBranch: $data['default_branch'] ?? null,
|
|
primaryLanguage: $data['language'] ?? null,
|
|
license: $data['license']['spdx_id'] ?? null,
|
|
topics: array_values((array) ($data['topics'] ?? [])),
|
|
languages: $this->fetchLanguages($fullName),
|
|
stars: (int) ($data['stargazers_count'] ?? 0),
|
|
forks: (int) ($data['forks_count'] ?? 0),
|
|
watchers: (int) ($data['subscribers_count'] ?? 0),
|
|
openIssuesCount: (int) ($data['open_issues_count'] ?? 0),
|
|
contributorsCount: $this->fetchContributorCount($fullName),
|
|
commitsLastYear: 0,
|
|
isArchived: (bool) ($data['archived'] ?? false),
|
|
isFork: (bool) ($data['fork'] ?? false),
|
|
pushedAt: $this->date($data['pushed_at'] ?? null),
|
|
createdAt: $this->date($data['created_at'] ?? null),
|
|
issues: $this->fetchIssues($fullName),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open issues worth showing a newcomer.
|
|
*
|
|
* @return array<int, IssueSnapshot>
|
|
*/
|
|
protected function fetchIssues(string $fullName): array
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/issues", [
|
|
'state' => 'open',
|
|
'per_page' => 100,
|
|
'sort' => 'updated',
|
|
'direction' => 'desc',
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return [];
|
|
}
|
|
|
|
$issues = [];
|
|
|
|
foreach ((array) $response->json() as $issue) {
|
|
// The issues endpoint returns pull requests too; they are not
|
|
// something a newcomer picks up.
|
|
if (isset($issue['pull_request'])) {
|
|
continue;
|
|
}
|
|
|
|
$labels = array_values(array_map(
|
|
fn (array $label) => (string) $label['name'],
|
|
(array) ($issue['labels'] ?? []),
|
|
));
|
|
|
|
$isGoodFirst = IssueSnapshot::matchesLabelGroup($labels, 'good_first_issue');
|
|
$isHelpWanted = IssueSnapshot::matchesLabelGroup($labels, 'help_wanted');
|
|
|
|
// Keep only the issues that serve the purpose. Mirroring every
|
|
// open issue would turn the site into a noisy tracker.
|
|
if (! $isGoodFirst && ! $isHelpWanted) {
|
|
continue;
|
|
}
|
|
|
|
$issues[] = new IssueSnapshot(
|
|
number: (int) $issue['number'],
|
|
title: (string) $issue['title'],
|
|
htmlUrl: (string) $issue['html_url'],
|
|
labels: $labels,
|
|
isGoodFirstIssue: $isGoodFirst,
|
|
isHelpWanted: $isHelpWanted,
|
|
author: $issue['user']['login'] ?? null,
|
|
assignee: $issue['assignee']['login'] ?? null,
|
|
comments: (int) ($issue['comments'] ?? 0),
|
|
createdAt: $this->date($issue['created_at'] ?? null),
|
|
updatedAt: $this->date($issue['updated_at'] ?? null),
|
|
);
|
|
}
|
|
|
|
return array_slice($issues, 0, (int) config('git.max_issues_per_repository', 25));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
protected function fetchLanguages(string $fullName): array
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/languages");
|
|
|
|
return $response->successful() ? (array) $response->json() : [];
|
|
}
|
|
|
|
/**
|
|
* GitHub does not expose a contributor count directly. Asking for a single
|
|
* contributor per page turns the count into the last page number, which
|
|
* costs one cheap request instead of paging through everyone.
|
|
*/
|
|
protected function fetchContributorCount(string $fullName): int
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/contributors", [
|
|
'per_page' => 1,
|
|
'anon' => 'true',
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return 0;
|
|
}
|
|
|
|
if ($link = $response->header('Link')) {
|
|
if (preg_match('/[?&]page=(\d+)>;\s*rel="last"/', $link, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
}
|
|
|
|
// No Link header means the results fit on one page.
|
|
return count((array) $response->json());
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
protected function get(string $path, array $query = []): Response
|
|
{
|
|
try {
|
|
return $this->request()->get($this->apiUrl.$path, $query);
|
|
} catch (ConnectionException $e) {
|
|
throw GitException::unreachable('github', $e);
|
|
}
|
|
}
|
|
|
|
protected function request(): PendingRequest
|
|
{
|
|
$request = Http::withHeaders([
|
|
'Accept' => 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version' => '2022-11-28',
|
|
])
|
|
->timeout((int) config('git.http_timeout', 15))
|
|
->retry(2, 300, throw: false);
|
|
|
|
return filled($this->token) ? $request->withToken($this->token) : $request;
|
|
}
|
|
|
|
/**
|
|
* Turn a bare "repo" into "owner/repo" using the configured organisation.
|
|
*/
|
|
protected function qualify(string $fullName): string
|
|
{
|
|
if (str_contains($fullName, '/') || blank($this->defaultOwner)) {
|
|
return trim($fullName, '/');
|
|
}
|
|
|
|
return "{$this->defaultOwner}/{$fullName}";
|
|
}
|
|
|
|
protected function assertNotRateLimited(Response $response): void
|
|
{
|
|
$remaining = $response->header('X-RateLimit-Remaining');
|
|
|
|
if ($response->status() === 403 && $remaining === '0') {
|
|
$reset = (int) $response->header('X-RateLimit-Reset');
|
|
|
|
throw GitException::rateLimited($reset > 0 ? max(0, $reset - time()) : null);
|
|
}
|
|
}
|
|
|
|
protected function assertSuccessful(Response $response, string $fullName): void
|
|
{
|
|
if ($response->failed()) {
|
|
throw new GitException(
|
|
"GitHub answered {$response->status()} for [{$fullName}].",
|
|
status: $response->status(),
|
|
// 5xx is the provider's problem and worth retrying; 4xx is ours.
|
|
isTransient: $response->serverError(),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected function date(?string $value): ?CarbonImmutable
|
|
{
|
|
return filled($value) ? CarbonImmutable::parse($value) : null;
|
|
}
|
|
}
|