$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 */ 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 */ 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 $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; } }