$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 */ 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 $repo * @return array */ 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 */ 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 $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; } }