Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/Console/Commands/SyncGitHubRepositoryStars.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Console\Commands;

use App\Support\GitHub;
use Illuminate\Console\Command;

class SyncGitHubRepositoryStars extends Command
{
protected $signature = 'github:sync-repo-stars';

protected $description = 'Fetch and cache the GitHub star counts shown in the site header for the mobile-air and desktop repositories';

public function handle(): int
{
$failed = false;

foreach ([GitHub::mobileAir(), GitHub::desktop()] as $repository) {
$stars = $repository->refreshStars();

if ($stars === null) {
$failed = true;
$this->error("Failed to fetch stars for {$repository->package}, keeping the last cached count");

continue;
}

$this->info("{$repository->package}: {$stars} stars");
}

return $failed ? self::FAILURE : self::SUCCESS;
}
}
6 changes: 6 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ protected function schedule(Schedule $schedule): void
->onOneServer()
->runInBackground();

// Refresh the repository star counts shown in the site header's Mobile and Desktop dropdowns
$schedule->command('github:sync-repo-stars')
->dailyAt('09:00')
->onOneServer()
->runInBackground();

// Remove Discord Max role for users with expired Max licenses
$schedule->command('discord:remove-expired-roles')
->dailyAt('10:30')
Expand Down
60 changes: 59 additions & 1 deletion app/Support/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
namespace App\Support;

use App\Support\GitHub\Release;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Number;
use Illuminate\Support\Str;

class GitHub
{
Expand All @@ -20,7 +24,7 @@ class GitHub
public const PACKAGE_MOBILE_AIR = 'nativephp/mobile-air';

public function __construct(
private string $package
public readonly string $package
) {}

public static function desktop(): static
Expand Down Expand Up @@ -105,6 +109,60 @@ public function releasesFrom(string $version): Collection
)->values();
}

/**
* The repository's star count as last cached by refreshStars(). Never hits
* the GitHub API, so it is safe to call while rendering a page.
*/
public function stars(): ?int
{
return Cache::get($this->getCacheKey('stars'));
}

/**
* The cached star count in a compact, human form such as "1.2k stars", or
* null when nothing has been cached yet.
*/
public function formattedStars(): ?string
{
$stars = $this->stars();

if ($stars === null) {
return null;
}

return Str::lower(Number::abbreviate($stars, maxPrecision: 1)).' '.Str::plural('star', $stars);
}

/**
* Fetch the star count from GitHub and cache it with no expiry. A failed
* request leaves the previously cached count in place.
*/
public function refreshStars(): ?int
{
try {
$response = Http::timeout(10)
->accept('application/vnd.github+json')
->when(config('services.github.token'), fn ($request, string $token) => $request->withToken($token))
->get('https://api.github.com/repos/'.$this->package);
} catch (ConnectionException $e) {
Log::warning('Failed to fetch GitHub star count', ['repository' => $this->package, 'error' => $e->getMessage()]);

return null;
}

$stars = $response->json('stargazers_count');

if ($response->failed() || ! is_int($stars)) {
Log::warning('Failed to fetch GitHub star count', ['repository' => $this->package, 'status' => $response->status()]);

return null;
}

Cache::forever($this->getCacheKey('stars'), $stars);

return $stars;
}

private function fetchLatestVersion(): ?Release
{
// Make a request to GitHub
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@props([
'href' => '#',
'title' => '',
'titlePrefix' => null,
'subtitle' => null,
'icon' => null,
'github' => null,
Expand All @@ -10,7 +11,7 @@

<a
href="{{ $href }}"
@if ($title) aria-label="{{ $title }}" @endif
@if ($title) aria-label="{{ $titlePrefix }}{{ $title }}" @endif
class="group flex items-center gap-3 rounded-lg py-2 pr-3 pl-2 ring-1 ring-transparent transition hover:bg-snow-flurry-50/70 hover:ring-snow-flurry-100 active:bg-snow-flurry-50/70 active:ring-snow-flurry-100 dark:hover:bg-violet-400/40 dark:hover:ring-violet-400/70 dark:active:bg-violet-400/40 dark:active:ring-violet-400/70"
role="menuitem"
tabindex="-1"
Expand All @@ -29,7 +30,7 @@ class="{{ $iconClass }} transition will-change-transform group-hover:scale-95 gr

<div class="relative grow truncate pr-5">
@if ($title)
<div class="font-medium">{{ $title }}</div>
<div class="font-medium">@if ($titlePrefix)<span class="opacity-60">{{ $titlePrefix }}</span>@endif{{ $title }}</div>
@endif

@if ($subtitle)
Expand Down
12 changes: 8 additions & 4 deletions resources/views/components/navbar/device-dropdowns.blade.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@php
$showShowcase = \App\Models\Showcase::approved()->count() >= 4;
$mobileAirStars = \App\Support\GitHub::mobileAir()->formattedStars();
$desktopStars = \App\Support\GitHub::desktop()->formattedStars();
@endphp

<nav class="flex items-center gap-1.5 lg:gap-2">
Expand Down Expand Up @@ -35,8 +37,9 @@
@endif
<x-navbar.device-dropdown-item
href="https://github.com/nativephp/mobile-air"
title="GitHub"
subtitle="Visit our GitHub repository"
title-prefix="nativephp/"
title="mobile-air"
:subtitle="$mobileAirStars ?? 'GitHub'"
icon="github"
/>
</x-navbar.device-dropdown>
Expand Down Expand Up @@ -65,8 +68,9 @@
@endif
<x-navbar.device-dropdown-item
href="https://github.com/nativephp/desktop"
title="GitHub"
subtitle="Visit our GitHub repository"
title-prefix="nativephp/"
title="desktop"
:subtitle="$desktopStars ?? 'GitHub'"
icon="github"
/>
</x-navbar.device-dropdown>
Expand Down
186 changes: 186 additions & 0 deletions tests/Feature/GitHubRepositoryStarsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

namespace Tests\Feature;

use App\Support\GitHub;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

class GitHubRepositoryStarsTest extends TestCase
{
use RefreshDatabase;

protected function setUp(): void
{
parent::setUp();

Cache::flush();
}

private function fakeStars(int $mobileAir = 1234, int $desktop = 5678): void
{
Http::fake([
'api.github.com/repos/nativephp/mobile-air' => Http::response(['stargazers_count' => $mobileAir]),
'api.github.com/repos/nativephp/desktop' => Http::response(['stargazers_count' => $desktop]),
]);
}

public function test_command_fetches_and_caches_star_counts_for_both_repositories(): void
{
$this->fakeStars();

$this->artisan('github:sync-repo-stars')->assertSuccessful();

$this->assertSame(1234, GitHub::mobileAir()->stars());
$this->assertSame(5678, GitHub::desktop()->stars());

Http::assertSent(fn (Request $request) => $request->url() === 'https://api.github.com/repos/nativephp/mobile-air');
Http::assertSent(fn (Request $request) => $request->url() === 'https://api.github.com/repos/nativephp/desktop');
Http::assertSentCount(2);
}

public function test_command_is_scheduled_daily(): void
{
$event = collect(app(Schedule::class)->events())
->first(fn ($event) => str_contains($event->command, 'github:sync-repo-stars'));

$this->assertNotNull($event);
$this->assertSame('0 9 * * *', $event->expression);
}

public function test_request_uses_the_configured_github_token(): void
{
config(['services.github.token' => 'test-token']);
$this->fakeStars();

GitHub::mobileAir()->refreshStars();

Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-token'));
}

public function test_request_is_sent_without_a_token_when_none_is_configured(): void
{
config(['services.github.token' => null]);
$this->fakeStars();

GitHub::mobileAir()->refreshStars();

Http::assertSent(fn (Request $request) => ! $request->hasHeader('Authorization'));
}

public function test_failed_api_call_keeps_the_previously_cached_count(): void
{
Http::fake([
'api.github.com/repos/nativephp/mobile-air' => Http::sequence()
->push(['stargazers_count' => 900])
->push(['message' => 'API rate limit exceeded'], 403),
'api.github.com/repos/nativephp/desktop' => Http::sequence()
->push(['stargazers_count' => 400])
->pushFailedConnection(),
]);

$this->artisan('github:sync-repo-stars')->assertSuccessful();
$this->artisan('github:sync-repo-stars')->assertFailed();

$this->assertSame(900, GitHub::mobileAir()->stars());
$this->assertSame(400, GitHub::desktop()->stars());
}

public function test_response_without_a_star_count_keeps_the_previously_cached_count(): void
{
Http::fake([
'api.github.com/repos/nativephp/mobile-air' => Http::sequence()
->push(['stargazers_count' => 900])
->push(['unexpected' => 'shape']),
]);

GitHub::mobileAir()->refreshStars();

$this->assertNull(GitHub::mobileAir()->refreshStars());
$this->assertSame(900, GitHub::mobileAir()->stars());
}

public function test_stars_are_null_when_nothing_has_been_cached(): void
{
Http::fake();

$this->assertNull(GitHub::mobileAir()->stars());
$this->assertNull(GitHub::mobileAir()->formattedStars());

Http::assertNothingSent();
}

/**
* @return array<string, array{int, string}>
*/
public static function starCountProvider(): array
{
return [
'zero' => [0, '0 stars'],
'one' => [1, '1 star'],
'hundreds' => [950, '950 stars'],
'exact thousand' => [1000, '1k stars'],
'thousands' => [1234, '1.2k stars'],
'tens of thousands' => [12345, '12.3k stars'],
];
}

#[DataProvider('starCountProvider')]
public function test_star_counts_are_formatted_compactly(int $count, string $expected): void
{
Http::fake(['api.github.com/repos/nativephp/mobile-air' => Http::response(['stargazers_count' => $count])]);

GitHub::mobileAir()->refreshStars();

$this->assertSame($expected, GitHub::mobileAir()->formattedStars());
}

public function test_dropdowns_show_repository_names_and_cached_star_counts(): void
{
$this->fakeStars();
$this->artisan('github:sync-repo-stars');

$this->blade('<x-navbar.device-dropdowns />')
->assertSeeInOrder(['https://github.com/nativephp/mobile-air', '<span class="opacity-60">nativephp/</span>mobile-air', '1.2k stars'], false)
->assertSeeInOrder(['https://github.com/nativephp/desktop', '<span class="opacity-60">nativephp/</span>desktop', '5.7k stars'], false)
->assertSee('aria-label="nativephp/mobile-air"', false)
->assertSee('aria-label="nativephp/desktop"', false)
->assertDontSee('Visit our GitHub repository');
}

public function test_dropdowns_fall_back_to_plain_text_when_no_star_counts_are_cached(): void
{
Http::fake();

$this->blade('<x-navbar.device-dropdowns />')
->assertSeeInOrder(['https://github.com/nativephp/mobile-air', 'nativephp/</span>mobile-air', 'GitHub'], false)
->assertSeeInOrder(['https://github.com/nativephp/desktop', 'nativephp/</span>desktop', 'GitHub'], false)
->assertDontSeeText('stars');

Http::assertNothingSent();
}

public function test_rendering_the_dropdowns_never_calls_the_github_api(): void
{
$this->fakeStars();
$this->artisan('github:sync-repo-stars');
Http::fake();

$this->blade('<x-navbar.device-dropdowns />')->assertSee('1.2k stars');

Http::assertNothingSent();
}

public function test_dropdown_items_without_a_title_prefix_render_the_title_alone(): void
{
$this->blade('<x-navbar.device-dropdown-item href="/docs" title="Documentation" />')
->assertSee('<div class="font-medium">Documentation</div>', false)
->assertSee('aria-label="Documentation"', false)
->assertDontSee('opacity-60', false);
}
}
Loading