githubDefaults = ['provider' => GitProvider::GitHub]; } /** @var array */ protected array $githubDefaults = []; /** * @param array $repo * @param array> $issues */ protected function fakeGitHub(array $repo = [], array $issues = []): void { Http::fake([ 'api.github.com/repos/*/languages' => Http::response(['PHP' => 90000, 'Blade' => 12000]), 'api.github.com/repos/*/contributors*' => Http::response([['login' => 'a']], 200, [ // GitHub reports the total through the Link header's last page. 'Link' => '; rel="last"', ]), 'api.github.com/repos/*/issues*' => Http::response($issues), 'api.github.com/repos/*' => Http::response([ 'full_name' => 'colectivo/web', 'html_url' => 'https://github.com/colectivo/web', 'description' => 'La web del colectivo', 'default_branch' => 'main', 'language' => 'PHP', 'license' => ['spdx_id' => 'AGPL-3.0'], 'topics' => ['laravel', 'community'], 'stargazers_count' => 128, 'forks_count' => 9, 'subscribers_count' => 5, 'open_issues_count' => 12, 'archived' => false, 'fork' => false, 'pushed_at' => '2026-08-01T10:00:00Z', 'created_at' => '2024-01-01T00:00:00Z', ...$repo, ]), ]); } #[Test] public function it_mirrors_the_repository_stats(): void { $this->fakeGitHub(); $repository = Repository::factory()->create([ ...$this->githubDefaults, 'full_name' => 'colectivo/web', 'stars' => 0, 'last_synced_at' => null, ]); app(GitManager::class)->sync($repository); $repository->refresh(); $this->assertSame(128, $repository->stars); $this->assertSame('PHP', $repository->primary_language); $this->assertSame('AGPL-3.0', $repository->license); $this->assertSame(['laravel', 'community'], $repository->topics); $this->assertSame(['PHP' => 90000, 'Blade' => 12000], $repository->languages); // Contributor count is read from the Link header's last page rather // than by paging through every contributor. $this->assertSame(7, $repository->contributors_count); $this->assertNotNull($repository->last_synced_at); $this->assertNull($repository->sync_error); } #[Test] public function it_only_mirrors_newcomer_friendly_issues(): void { $this->fakeGitHub(issues: [ [ 'number' => 1, 'title' => 'Good one', 'html_url' => 'https://x/1', 'labels' => [['name' => 'good first issue']], 'comments' => 2, 'user' => ['login' => 'ada'], 'assignee' => null, 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', ], [ 'number' => 2, 'title' => 'Help please', 'html_url' => 'https://x/2', 'labels' => [['name' => 'Help Wanted']], 'comments' => 0, 'user' => ['login' => 'ada'], 'assignee' => ['login' => 'bob'], 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', ], // Ordinary issue: noise for a newcomer, so it is not mirrored. [ 'number' => 3, 'title' => 'Refactor internals', 'html_url' => 'https://x/3', 'labels' => [['name' => 'chore']], 'comments' => 0, 'user' => ['login' => 'ada'], 'assignee' => null, 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', ], // A pull request is not something anyone picks up. [ 'number' => 4, 'title' => 'A PR', 'html_url' => 'https://x/4', 'labels' => [['name' => 'good first issue']], 'pull_request' => ['url' => 'x'], 'user' => ['login' => 'ada'], 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', ], ]); $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); app(GitManager::class)->sync($repository); $this->assertSame(2, $repository->issues()->count()); $this->assertSame(1, $repository->goodFirstIssues()->count()); // Label matching is case-insensitive, per config/git.php. $helpWanted = $repository->issues()->where('number', 2)->first(); $this->assertTrue($helpWanted->is_help_wanted); $this->assertSame('bob', $helpWanted->assignee); $this->assertFalse($helpWanted->isUnclaimed()); } #[Test] public function issues_closed_upstream_disappear_here(): void { $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); $repository->issues()->create([ 'number' => 99, 'title' => 'Closed since', 'html_url' => 'https://x/99', 'is_good_first_issue' => true, ]); $this->fakeGitHub(issues: []); app(GitManager::class)->sync($repository); // Advertising an issue that is actually closed wastes a would-be // contributor's time, so the mirror is replaced, not merged. $this->assertSame(0, $repository->issues()->count()); } #[Test] public function a_missing_repository_is_recorded_and_not_retried(): void { Http::fake([ 'api.github.com/*' => Http::response(['message' => 'Not Found'], 404), ]); $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']); try { app(GitManager::class)->sync($repository); $this->fail('Expected a GitException.'); } catch (GitException $e) { $this->assertSame(404, $e->status); $this->assertFalse($e->isTransient); } $repository->refresh(); $this->assertStringContainsString('does not exist', $repository->sync_error); $this->assertSame(1, $repository->sync_failures); } #[Test] public function repeated_failures_flag_the_repository_in_the_panel(): void { Http::fake(['api.github.com/*' => Http::response([], 404)]); $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']); foreach (range(1, 3) as $ignored) { rescue(fn () => app(GitManager::class)->sync($repository)); } $this->assertSame(3, $repository->fresh()->sync_failures); $this->assertTrue(Repository::query()->failing()->exists()); } #[Test] public function a_rate_limit_is_reported_as_transient(): void { Http::fake([ 'api.github.com/*' => Http::response(['message' => 'rate limit'], 403, [ 'X-RateLimit-Remaining' => '0', 'X-RateLimit-Reset' => (string) (time() + 120), ]), ]); $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); try { app(GitManager::class)->sync($repository); $this->fail('Expected a GitException.'); } catch (GitException $e) { // Transient means the job should back off and retry, not give up. $this->assertTrue($e->isTransient); $this->assertSame(429, $e->status); $this->assertStringContainsString('GITHUB_TOKEN', $e->getMessage()); } } #[Test] public function a_successful_sync_clears_a_previous_error(): void { $repository = Repository::factory()->failing()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); $this->fakeGitHub(); app(GitManager::class)->sync($repository); $repository->refresh(); $this->assertNull($repository->sync_error); $this->assertSame(0, $repository->sync_failures); } #[Test] public function synced_columns_cannot_be_mass_assigned(): void { // A hand-typed star count is a lie waiting to happen, so the sync // columns are deliberately absent from the fillable list. $repository = new Repository; $repository->fill(['full_name' => 'colectivo/web', 'stars' => 9999]); $this->assertSame('colectivo/web', $repository->full_name); $this->assertNull($repository->stars); } #[Test] public function a_bare_name_is_qualified_with_the_configured_org(): void { config()->set('git.providers.github.default_owner', 'colectivo'); $this->fakeGitHub(); $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'web']); app(GitManager::class)->sync($repository); // The provider's own answer wins, so the record self-corrects. $this->assertSame('colectivo/web', $repository->fresh()->full_name); Http::assertSent(fn ($request) => str_contains($request->url(), '/repos/colectivo/web')); } #[Test] public function an_unsupported_provider_fails_clearly(): void { $repository = Repository::factory()->create(['provider' => 'gitlab']); $this->expectException(GitException::class); $this->expectExceptionMessage('No driver is registered for the [gitlab] provider'); app(GitManager::class)->sync($repository); } }