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>
296 lines
10 KiB
PHP
296 lines
10 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 a self-hosted Gitea instance.
|
|
*
|
|
* Gitea's REST API is modelled on GitHub's but is not identical, and the
|
|
* differences that matter are handled here rather than leaking outwards:
|
|
*
|
|
* - Counters are named `stars_count` / `forks_count`, not `stargazers_count`.
|
|
* - Topics live on their own endpoint instead of the repository payload.
|
|
* - Issues accept `type=issues`, which filters out pull requests server-side.
|
|
* - There is no contributors endpoint, so the count is derived from the
|
|
* distinct authors of recent commits (see contributorCount()).
|
|
* - Authentication uses the `token` scheme rather than `Bearer`.
|
|
*/
|
|
class GiteaProvider implements RepositoryProvider
|
|
{
|
|
/**
|
|
* How many recent commits to inspect when approximating the contributor
|
|
* count. One request, and enough history to be representative without
|
|
* walking the whole log.
|
|
*/
|
|
protected const COMMIT_SAMPLE = 100;
|
|
|
|
public function __construct(
|
|
protected string $baseUrl,
|
|
protected ?string $token = null,
|
|
protected ?string $defaultOwner = null,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public static function fromConfig(array $config): self
|
|
{
|
|
$url = rtrim((string) ($config['url'] ?? ''), '/');
|
|
|
|
return new self(
|
|
// Gitea always serves its API under /api/v1 of the instance root.
|
|
baseUrl: $url === '' ? '' : $url.'/api/v1',
|
|
token: $config['token'] ?? null,
|
|
defaultOwner: $config['default_owner'] ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Unlike a hosted service, a self-hosted instance has no default address:
|
|
* without GITEA_URL there is nothing to talk to.
|
|
*/
|
|
public function isConfigured(): bool
|
|
{
|
|
return filled($this->baseUrl);
|
|
}
|
|
|
|
public function fetch(string $fullName): RepositorySnapshot
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
throw new GitException(
|
|
'The Gitea instance is not configured: set GITEA_URL to your instance root, e.g. https://git.example.org.'
|
|
);
|
|
}
|
|
|
|
$fullName = $this->qualify($fullName);
|
|
|
|
$response = $this->get("/repos/{$fullName}");
|
|
|
|
if ($response->status() === 404) {
|
|
throw GitException::notFound($fullName);
|
|
}
|
|
|
|
// A private repository on a self-hosted instance answers 403 without a
|
|
// token, which is a configuration problem rather than a missing repo.
|
|
if ($response->status() === 403) {
|
|
throw new GitException(
|
|
"Gitea denied access to [{$fullName}]. If it is private, set GITEA_TOKEN to a token that can read it.",
|
|
status: 403,
|
|
);
|
|
}
|
|
|
|
$this->assertSuccessful($response, $fullName);
|
|
|
|
$data = (array) $response->json();
|
|
|
|
return new RepositorySnapshot(
|
|
fullName: (string) ($data['full_name'] ?? $fullName),
|
|
htmlUrl: $data['html_url'] ?? null,
|
|
description: filled($data['description'] ?? null) ? $data['description'] : null,
|
|
homepage: filled($data['website'] ?? null) ? $data['website'] : null,
|
|
defaultBranch: $data['default_branch'] ?? null,
|
|
primaryLanguage: filled($data['language'] ?? null) ? $data['language'] : null,
|
|
// Gitea does not report a licence in the repository payload.
|
|
license: null,
|
|
topics: $this->topics($fullName, $data),
|
|
languages: $this->languages($fullName),
|
|
stars: (int) ($data['stars_count'] ?? 0),
|
|
forks: (int) ($data['forks_count'] ?? 0),
|
|
watchers: (int) ($data['watchers_count'] ?? 0),
|
|
openIssuesCount: (int) ($data['open_issues_count'] ?? 0),
|
|
contributorsCount: $this->contributorCount($fullName),
|
|
commitsLastYear: 0,
|
|
isArchived: (bool) ($data['archived'] ?? false),
|
|
isFork: (bool) ($data['fork'] ?? false),
|
|
// Gitea has no `pushed_at`; `updated_at` moves on push.
|
|
pushedAt: $this->date($data['updated_at'] ?? null),
|
|
createdAt: $this->date($data['created_at'] ?? null),
|
|
issues: $this->issues($fullName),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open issues worth showing a newcomer.
|
|
*
|
|
* @return array<int, IssueSnapshot>
|
|
*/
|
|
protected function issues(string $fullName): array
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/issues", [
|
|
'state' => 'open',
|
|
// Gitea can exclude pull requests server-side, unlike GitHub.
|
|
'type' => 'issues',
|
|
'limit' => 100,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return [];
|
|
}
|
|
|
|
$issues = [];
|
|
|
|
foreach ((array) $response->json() as $issue) {
|
|
$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');
|
|
|
|
// Mirroring every open issue would turn the site into a noisy
|
|
// tracker; only the ones that are an invitation are kept.
|
|
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,
|
|
// Gitea exposes `assignees` as well as the legacy `assignee`.
|
|
assignee: $issue['assignee']['login']
|
|
?? ($issue['assignees'][0]['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));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $repo
|
|
* @return array<int, string>
|
|
*/
|
|
protected function topics(string $fullName, array $repo): array
|
|
{
|
|
// Some Gitea versions do include topics inline; prefer that over a
|
|
// second request.
|
|
if (is_array($repo['topics'] ?? null) && $repo['topics'] !== []) {
|
|
return array_values(array_map('strval', $repo['topics']));
|
|
}
|
|
|
|
$response = $this->get("/repos/{$fullName}/topics");
|
|
|
|
if ($response->failed()) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_map('strval', (array) ($response->json('topics') ?? [])));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
protected function languages(string $fullName): array
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/languages");
|
|
|
|
return $response->successful() ? array_map('intval', (array) $response->json()) : [];
|
|
}
|
|
|
|
/**
|
|
* Gitea exposes no contributors endpoint, so this counts the distinct
|
|
* authors of the most recent commits.
|
|
*
|
|
* It is an approximation by design: for a small collective's repositories
|
|
* the recent history covers everyone who is actually involved, and it
|
|
* costs one request instead of walking the entire log.
|
|
*/
|
|
protected function contributorCount(string $fullName): int
|
|
{
|
|
$response = $this->get("/repos/{$fullName}/commits", [
|
|
'limit' => self::COMMIT_SAMPLE,
|
|
'stat' => 'false',
|
|
'verification' => 'false',
|
|
'files' => 'false',
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return 0;
|
|
}
|
|
|
|
$authors = [];
|
|
|
|
foreach ((array) $response->json() as $commit) {
|
|
// Prefer the Gitea account; fall back to the raw commit email so
|
|
// contributors without an account still count once each.
|
|
$identity = $commit['author']['login']
|
|
?? $commit['commit']['author']['email']
|
|
?? null;
|
|
|
|
if (filled($identity)) {
|
|
$authors[mb_strtolower((string) $identity)] = true;
|
|
}
|
|
}
|
|
|
|
return count($authors);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
protected function get(string $path, array $query = []): Response
|
|
{
|
|
try {
|
|
return $this->request()->get($this->baseUrl.$path, $query);
|
|
} catch (ConnectionException $e) {
|
|
throw GitException::unreachable('gitea', $e);
|
|
}
|
|
}
|
|
|
|
protected function request(): PendingRequest
|
|
{
|
|
$request = Http::acceptJson()
|
|
->timeout((int) config('git.http_timeout', 15))
|
|
->retry(2, 300, throw: false);
|
|
|
|
// Gitea's own scheme. It also accepts Bearer on recent versions, but
|
|
// `token` works across every release we might meet.
|
|
return filled($this->token)
|
|
? $request->withHeaders(['Authorization' => 'token '.$this->token])
|
|
: $request;
|
|
}
|
|
|
|
protected function qualify(string $fullName): string
|
|
{
|
|
if (str_contains($fullName, '/') || blank($this->defaultOwner)) {
|
|
return trim($fullName, '/');
|
|
}
|
|
|
|
return "{$this->defaultOwner}/{$fullName}";
|
|
}
|
|
|
|
protected function assertSuccessful(Response $response, string $fullName): void
|
|
{
|
|
if ($response->failed()) {
|
|
throw new GitException(
|
|
"Gitea answered {$response->status()} for [{$fullName}].",
|
|
status: $response->status(),
|
|
// A self-hosted instance being down is temporary; a 4xx is not.
|
|
isTransient: $response->serverError(),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected function date(?string $value): ?CarbonImmutable
|
|
{
|
|
return filled($value) ? CarbonImmutable::parse($value) : null;
|
|
}
|
|
}
|