Initial commit: Pireon Admin — headless admin panel and public API

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>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services\Git\Contracts;
use App\Services\Git\GitException;
use App\Services\Git\RepositorySnapshot;
/**
* A git hosting provider this application can read from.
*
* Implementations are responsible for talking to one provider's API and
* translating its answer into the provider-neutral shapes in
* App\Services\Git — nothing outside this namespace should know whether a
* repository lives on GitHub, GitLab or Gitea.
*/
interface RepositoryProvider
{
/**
* Fetch everything the application shows about a repository.
*
* @param string $fullName "owner/repo"
*
* @throws GitException when the repository is missing,
* inaccessible, or the provider is unreachable.
*/
public function fetch(string $fullName): RepositorySnapshot;
/**
* Whether this driver is usable right now. A driver may work without
* credentials but be too rate-limited to be practical.
*/
public function isConfigured(): bool;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services\Git;
use RuntimeException;
use Throwable;
class GitException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
/**
* Whether retrying later might succeed. Rate limits and outages are
* transient; a deleted repository is not, and the job should stop
* retrying rather than hammer the provider.
*/
public readonly bool $isTransient = false,
?Throwable $previous = null,
) {
parent::__construct($message, $status ?? 0, $previous);
}
public static function notFound(string $fullName): self
{
return new self(
"The repository [{$fullName}] does not exist, or the configured token cannot see it.",
status: 404,
);
}
public static function rateLimited(?int $resetsInSeconds = null): self
{
$when = $resetsInSeconds !== null
? " Try again in about {$resetsInSeconds}s."
: '';
return new self(
"The provider's rate limit has been reached.{$when} ".
'Configure GITHUB_TOKEN to raise it from 60 to 5000 requests per hour.',
status: 429,
isTransient: true,
);
}
public static function unreachable(string $provider, ?Throwable $previous = null): self
{
return new self(
"The [{$provider}] API could not be reached.",
isTransient: true,
previous: $previous,
);
}
public static function unsupportedProvider(string $provider): self
{
return new self(
"No driver is registered for the [{$provider}] provider. ".
'Add one to config/git.php.'
);
}
}

View File

@@ -0,0 +1,248 @@
<?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;
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Services\Git;
use App\Enums\GitProvider;
use App\Models\Repository;
use App\Services\Git\Contracts\RepositoryProvider;
use Illuminate\Support\Facades\DB;
/**
* Resolves the right driver for a repository and applies what it returns.
*
* This is the only class that both knows about drivers and touches models,
* which keeps the drivers free of persistence concerns and the models free of
* HTTP concerns.
*/
class GitManager
{
/** @var array<string, RepositoryProvider> */
protected array $drivers = [];
public function driver(GitProvider|string $provider): RepositoryProvider
{
$key = $provider instanceof GitProvider ? $provider->value : $provider;
if (isset($this->drivers[$key])) {
return $this->drivers[$key];
}
$config = config("git.providers.{$key}");
if (! is_array($config) || ! isset($config['driver'])) {
throw GitException::unsupportedProvider($key);
}
/** @var class-string $class */
$class = $config['driver'];
return $this->drivers[$key] = $class::fromConfig($config);
}
/**
* Refresh one repository from its provider.
*
* Both outcomes are recorded: a success clears any previous error, and a
* failure increments a counter so the panel can flag repositories that
* have been broken for a while instead of quietly showing stale numbers.
*/
public function sync(Repository $repository): Repository
{
try {
$snapshot = $this->driver($repository->provider)->fetch($repository->full_name);
} catch (GitException $e) {
$repository->forceFill([
'sync_error' => $e->getMessage(),
'sync_failures' => $repository->sync_failures + 1,
'last_synced_at' => $repository->last_synced_at,
])->save();
throw $e;
}
DB::transaction(function () use ($repository, $snapshot): void {
$repository->forceFill([
...$snapshot->toAttributes(),
'full_name' => $snapshot->fullName,
'sync_error' => null,
'sync_failures' => 0,
'last_synced_at' => now(),
])->save();
$this->syncIssues($repository, $snapshot);
});
return $repository->refresh();
}
/**
* Replace the mirrored issues with what the provider currently reports.
*
* Issues that were closed or had their label removed upstream must
* disappear here too — an "open" issue on the site that is actually closed
* wastes a would-be contributor's time.
*/
protected function syncIssues(Repository $repository, RepositorySnapshot $snapshot): void
{
$seen = [];
foreach ($snapshot->issues as $issue) {
$repository->issues()->updateOrCreate(
['number' => $issue->number],
$issue->toAttributes(),
);
$seen[] = $issue->number;
}
$repository->issues()
->when($seen !== [], fn ($q) => $q->whereNotIn('number', $seen))
->delete();
}
}

View File

@@ -0,0 +1,295 @@
<?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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Services\Git;
use Carbon\CarbonImmutable;
/**
* A provider-neutral open issue.
*/
final readonly class IssueSnapshot
{
public function __construct(
public int $number,
public string $title,
public string $htmlUrl,
/** @var array<int, string> */
public array $labels = [],
public bool $isGoodFirstIssue = false,
public bool $isHelpWanted = false,
public ?string $author = null,
public ?string $assignee = null,
public int $comments = 0,
public ?CarbonImmutable $createdAt = null,
public ?CarbonImmutable $updatedAt = null,
) {}
/**
* Decide whether a label set marks an issue as newcomer-friendly, using
* the spellings configured in config/git.php.
*
* @param array<int, string> $labels
*/
public static function matchesLabelGroup(array $labels, string $group): bool
{
$wanted = array_map('mb_strtolower', config("git.labels.{$group}", []));
$present = array_map('mb_strtolower', $labels);
return array_intersect($wanted, $present) !== [];
}
/**
* @return array<string, mixed>
*/
public function toAttributes(): array
{
return [
'number' => $this->number,
'title' => $this->title,
'html_url' => $this->htmlUrl,
'labels' => $this->labels,
'is_good_first_issue' => $this->isGoodFirstIssue,
'is_help_wanted' => $this->isHelpWanted,
'author' => $this->author,
'assignee' => $this->assignee,
'comments' => $this->comments,
'issue_created_at' => $this->createdAt,
'issue_updated_at' => $this->updatedAt,
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Git;
use Carbon\CarbonImmutable;
/**
* A provider-neutral picture of a repository at one moment in time.
*
* Every driver returns one of these, so the models, jobs and UI never learn
* the shape of any particular provider's JSON.
*/
final readonly class RepositorySnapshot
{
public function __construct(
public string $fullName,
public ?string $htmlUrl = null,
public ?string $description = null,
public ?string $homepage = null,
public ?string $defaultBranch = null,
public ?string $primaryLanguage = null,
public ?string $license = null,
/** @var array<int, string> */
public array $topics = [],
/** @var array<string, int> Bytes of code per language. */
public array $languages = [],
public int $stars = 0,
public int $forks = 0,
public int $watchers = 0,
public int $openIssuesCount = 0,
public int $contributorsCount = 0,
public int $commitsLastYear = 0,
public bool $isArchived = false,
public bool $isFork = false,
public ?CarbonImmutable $pushedAt = null,
public ?CarbonImmutable $createdAt = null,
/** @var array<int, IssueSnapshot> */
public array $issues = [],
) {}
/**
* The columns this snapshot writes onto a Repository model.
*
* @return array<string, mixed>
*/
public function toAttributes(): array
{
return [
'html_url' => $this->htmlUrl,
'description' => $this->description,
'homepage' => $this->homepage,
'default_branch' => $this->defaultBranch,
'primary_language' => $this->primaryLanguage,
'license' => $this->license,
'topics' => $this->topics,
'languages' => $this->languages,
'stars' => $this->stars,
'forks' => $this->forks,
'watchers' => $this->watchers,
'open_issues_count' => $this->openIssuesCount,
'contributors_count' => $this->contributorsCount,
'commits_last_year' => $this->commitsLastYear,
'is_archived' => $this->isArchived,
'is_fork' => $this->isFork,
'pushed_at' => $this->pushedAt,
'repo_created_at' => $this->createdAt,
];
}
}