Repository navigation
feat: gather every commit about a GitHub repository on its own page #1176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Controllers; | ||
|
|
||
| use App\Models\User; | ||
| use App\Queries\GitHubRepositoryQuery; | ||
| use App\Queries\RepositoryCommitsQuery; | ||
| use Illuminate\Container\Attributes\CurrentUser; | ||
| use Inertia\Inertia; | ||
| use Inertia\Response; | ||
|
|
||
| /** | ||
| * Every commit about a GitHub repository on one page, with the repository as GitHub describes it. | ||
| */ | ||
| final readonly class RepositoryController | ||
| { | ||
| public function show(string $owner, string $repository, #[CurrentUser] ?User $user, RepositoryCommitsQuery $commits, GitHubRepositoryQuery $github): Response | ||
| { | ||
| return Inertia::render('repository/show', [ | ||
| 'owner' => $owner, | ||
| 'repo' => $repository, | ||
| 'github' => $github->get($owner, $repository), | ||
| 'stats' => $commits->stats($owner, $repository, $user), | ||
| 'commits' => Inertia::scroll(fn () => $commits->builder($owner, $repository, $user)->cursorPaginate(20)), | ||
| ]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Queries; | ||
|
|
||
| use App\Services\GitHub; | ||
| use Illuminate\Http\Client\HttpClientException; | ||
| use Illuminate\Support\Facades\Cache; | ||
|
|
||
| /** | ||
| * @phpstan-type Repository array{name: string, description: string|null, stars: int} | ||
| */ | ||
| final readonly class GitHubRepositoryQuery | ||
| { | ||
| private const int Ttl = 86400; | ||
|
|
||
| private const int FailureTtl = 300; | ||
|
|
||
| public function __construct(private GitHub $github) | ||
| { | ||
| // | ||
| } | ||
|
|
||
| /** | ||
| * A public repository's name as GitHub writes it, its description, and its stars, cached for a day. Null when it is | ||
| * private, gone, or GitHub didn't answer, which is remembered for a few minutes so an outage doesn't call GitHub | ||
| * again on every view. | ||
| * | ||
| * @return Repository|null | ||
| */ | ||
| public function get(string $owner, string $repository): ?array | ||
| { | ||
| $key = 'github-repository:'.mb_strtolower("{$owner}/{$repository}"); | ||
|
|
||
| /** @var Repository|false|null $found */ | ||
| $found = Cache::get($key); | ||
|
|
||
| if ($found === null) { | ||
| try { | ||
| $response = $this->github->request()->get("repos/{$owner}/{$repository}"); | ||
| $found = $response->notFound() ? false : [ | ||
| 'name' => $response->throw()->fluent()->string('full_name')->value(), | ||
| 'description' => $response->fluent()->get('description'), | ||
| 'stars' => $response->fluent()->integer('stargazers_count'), | ||
| ]; | ||
| } catch (HttpClientException) { | ||
| Cache::put($key, false, self::FailureTtl); | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| Cache::put($key, $found, self::Ttl); | ||
| } | ||
|
|
||
| return $found === false ? null : $found; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Queries; | ||
|
|
||
| use App\Models\Commit; | ||
| use App\Models\User; | ||
| use Illuminate\Database\Eloquent\Relations\BelongsTo; | ||
|
|
||
| /** | ||
| * The commits that link to a GitHub repository, or to anything in it like an issue or a file, newest first. | ||
| */ | ||
| final readonly class RepositoryCommitsQuery | ||
| { | ||
| public function builder(string $owner, string $repository, ?User $user): CommitBuilder | ||
| { | ||
| return $this->mentioning($owner, $repository) | ||
| ->visibleTo($user) | ||
| ->withoutMutedAuthorsFor($user) | ||
| ->withVisibleReplyCount($user) | ||
| ->withReplyPreviews($user) | ||
| ->withCardDataFor($user) | ||
| ->with([ | ||
| 'parent' => function (BelongsTo $parent) use ($user): void { | ||
| $parent->getQuery()->withTrashed()->visibleTo($user)->with('user'); | ||
| }, | ||
| ]) | ||
| ->newestFirst(); | ||
| } | ||
|
|
||
| /** | ||
| * @return array{commits: int, people: int} | ||
| */ | ||
| public function stats(string $owner, string $repository, ?User $user): array | ||
| { | ||
| $commits = $this->mentioning($owner, $repository)->visibleTo($user)->withoutMutedAuthorsFor($user); | ||
|
|
||
| return ['commits' => $commits->count(), 'people' => $commits->distinct()->count('user_id')]; | ||
| } | ||
|
|
||
| /** | ||
| * The link must be to github.com itself, and the repository's name must end where the link's does, so | ||
| * "laravel/framework" doesn't match "laravel/framework-docs", while a ".git" clone URL, a sentence's closing period, | ||
| * a path, or an issue number after it still count. The trigram index on the body keeps this from reading every commit. | ||
| */ | ||
| private function mentioning(string $owner, string $repository): CommitBuilder | ||
| { | ||
| $pattern = '(^|[^A-Za-z0-9.-])github\.com/'.preg_quote($owner).'/'.preg_quote($repository).'(\.git)?(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])'; | ||
|
|
||
| return Commit::query()->whereRaw('commits.body ~* ?', [$pattern]); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| /** | ||
| * Lets a repository's page find the commits linking to it without reading every commit. | ||
| */ | ||
| public function up(): void | ||
| { | ||
| Schema::ensureExtensionExists('pg_trgm'); | ||
|
|
||
| Schema::table('commits', function (Blueprint $table): void { | ||
| $table->rawIndex('body gin_trgm_ops', 'commits_body_trigram_index')->algorithm('gin'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Build the index concurrently to avoid blocking commit writes. Laravel 13.38.0 emits a plain The stress seeder documents about one million commits and several gigabytes of data, so write blocking is a plausible deployment risk. The PR already discloses this limitation, and production size and build duration are not established. Downgrade this from major to minor. Suggested fix
|
||
| }); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { Head } from '@inertiajs/react'; | ||
| import { Star } from 'lucide-react'; | ||
| import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; | ||
| import { GitHubIcon } from '@/components/github-icon'; | ||
| import AppLayout from '@/layouts/app-layout'; | ||
| import type { Commit, CursorPaginated } from '@/types'; | ||
|
|
||
| type Props = { | ||
| owner: string; | ||
| repo: string; | ||
| github: { name: string; description: string | null; stars: number } | null; | ||
| stats: { commits: number; people: number }; | ||
| commits: CursorPaginated<Commit>; | ||
| }; | ||
|
|
||
| export default function Show({ owner, repo, github, stats, commits }: Props) { | ||
| const name = github?.name ?? `${owner}/${repo}`; | ||
|
|
||
| return ( | ||
| <> | ||
| <Head title={name} /> | ||
|
|
||
| <header className="border-b px-4 py-4 sm:px-6"> | ||
| <h1 className="flex items-center gap-2 font-mono text-lg font-semibold wrap-anywhere"> | ||
| <GitHubIcon aria-hidden className="size-5 shrink-0" /> | ||
| <a | ||
| href={`https://github.com/${name}`} | ||
| target="_blank" | ||
| rel="noopener noreferrer nofollow" | ||
| className="hover:underline" | ||
| > | ||
| {name} | ||
| </a> | ||
| </h1> | ||
|
|
||
| {github?.description && ( | ||
| <p className="mt-1 text-[15px] text-muted-foreground"> | ||
| {github.description} | ||
| </p> | ||
| )} | ||
|
|
||
| <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> | ||
| {github && ( | ||
| <span className="inline-flex items-center gap-1 tabular-nums"> | ||
| <Star aria-hidden className="size-3.5" /> | ||
| {github.stars.toLocaleString()} | ||
| </span> | ||
| )} | ||
| {stats.commits > 0 && ( | ||
| <span> | ||
| {stats.commits} commit | ||
| {stats.commits === 1 ? '' : 's'} by{' '} | ||
| {stats.people === 1 | ||
| ? '1 person' | ||
| : `${stats.people} people`} | ||
| </span> | ||
| )} | ||
| </p> | ||
| </header> | ||
|
|
||
| <CommitFeed | ||
| commits={commits} | ||
| end="That's every commit about this repository." | ||
| empty={ | ||
| <EmptyFeed command={`fatal: no commits about ${name}`}> | ||
| Link to it in a commit and it shows up here. | ||
| </EmptyFeed> | ||
| } | ||
| /> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| Show.layout = AppLayout; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Models\Commit; | ||
| use App\Models\User; | ||
| use Illuminate\Support\Facades\Http; | ||
|
|
||
| beforeEach(function (): void { | ||
| Http::fake(['api.github.com/repos/laravel/framework' => Http::response([ | ||
| 'full_name' => 'laravel/framework', | ||
| 'description' => 'The Laravel Framework.', | ||
| 'stargazers_count' => 34981, | ||
| ])]); | ||
| }); | ||
|
|
||
| it('lists the commits linking to a repository or anything in it, newest first', function (): void { | ||
| $old = Commit::factory()->create(['body' => 'Look: https://github.com/laravel/framework.', 'created_at' => now()->subDay()]); | ||
| $issue = Commit::factory()->create(['body' => 'https://github.com/Laravel/Framework/issues/1', 'created_at' => now()->subHour()]); | ||
| $clone = Commit::factory()->create(['body' => 'git clone https://github.com/laravel/framework.git', 'created_at' => now()->subHours(2)]); | ||
| Commit::factory()->create(['body' => 'https://notgithub.com/laravel/framework']); | ||
| $reply = Commit::factory()->create(['body' => 'See github.com/laravel/framework/pull/2', 'parent_id' => $old->id]); | ||
| Commit::factory()->create(['body' => 'https://github.com/laravel/framework-docs']); | ||
| Commit::factory()->create(['body' => 'https://github.com/laravel/framework.js']); | ||
| Commit::factory()->create(['body' => 'laravel/framework without a link']); | ||
|
|
||
| $this->get(route('repository.show', ['laravel', 'framework']))->assertOk()->assertInertia(fn ($page) => $page | ||
| ->component('repository/show') | ||
| ->where('owner', 'laravel') | ||
| ->where('repo', 'framework') | ||
| ->where('github', ['name' => 'laravel/framework', 'description' => 'The Laravel Framework.', 'stars' => 34981]) | ||
| ->where('stats', ['commits' => 4, 'people' => 4]) | ||
| ->has('commits.data', 4) | ||
| ->where('commits.data.0.id', $reply->id) | ||
| ->where('commits.data.1.id', $issue->id) | ||
| ->where('commits.data.2.id', $clone->id) | ||
| ->where('commits.data.3.id', $old->id)); | ||
| }); | ||
|
|
||
| it('hides the commits the viewer may not see from the repository page', function (): void { | ||
| $viewer = User::factory()->create(); | ||
| $blocked = User::factory()->create(); | ||
| $muted = User::factory()->create(); | ||
| $viewer->blocking()->attach($blocked); | ||
| $viewer->muting()->attach($muted); | ||
| $body = 'https://github.com/laravel/framework'; | ||
| $visible = Commit::factory()->create(['body' => $body, 'created_at' => now()->subMinute()]); | ||
| Commit::factory()->for($blocked)->create(['body' => $body]); | ||
| Commit::factory()->for($muted)->create(['body' => $body]); | ||
| Commit::factory()->pending()->create(['body' => $body]); | ||
| $own = Commit::factory()->pending()->for($viewer)->create(['body' => $body]); | ||
|
|
||
| $this->actingAs($viewer)->get(route('repository.show', ['laravel', 'framework']))->assertOk()->assertInertia(fn ($page) => $page | ||
| ->where('stats', ['commits' => 2, 'people' => 2]) | ||
| ->has('commits.data', 2) | ||
| ->where('commits.data.0.id', $own->id) | ||
| ->where('commits.data.1.id', $visible->id)); | ||
| }); | ||
|
|
||
| it('shows the commits without GitHub details when the repository is private or gone', function (): void { | ||
| Http::fake(['api.github.com/repos/someone/secret' => Http::response(status: 404)]); | ||
| Commit::factory()->create(['body' => 'https://github.com/someone/secret']); | ||
|
|
||
| $this->get(route('repository.show', ['someone', 'secret']))->assertOk()->assertInertia(fn ($page) => $page | ||
| ->where('github', null) | ||
| ->has('commits.data', 1)); | ||
|
|
||
| $this->get(route('repository.show', ['someone', 'secret']))->assertOk(); | ||
|
|
||
| Http::assertSentCount(1); | ||
| }); | ||
|
|
||
| it('remembers for a few minutes that GitHub did not answer', function (): void { | ||
| Http::fake(['api.github.com/repos/down/repo' => Http::response(status: 500)]); | ||
|
|
||
| $this->get(route('repository.show', ['down', 'repo']))->assertOk()->assertInertia(fn ($page) => $page | ||
| ->where('github', null) | ||
| ->where('stats', ['commits' => 0, 'people' => 0]) | ||
| ->has('commits.data', 0)); | ||
|
|
||
| $this->get(route('repository.show', ['down', 'repo']))->assertOk(); | ||
|
|
||
| Http::assertSentCount(1); | ||
| }); | ||
|
|
||
| it('only takes names GitHub allows', function (): void { | ||
| $this->get('/github/laravel/framework%20docs')->assertNotFound(); | ||
| $this->get('/github/laravel/..')->assertNotFound(); | ||
| $this->get('/github/laravel/.')->assertNotFound(); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Match the GitHub host, not a path component.
If a commit contains
https://evil.example/github.com/laravel/framework, the slash beforegithub.comsatisfies this prefix condition. The commit then appears on/github/laravel/frameworkeven though its link points to another host. Requiregithub.comto be the URL host, and add this case to the matching tests.