diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 19e85dff8..811a88ea2 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -23,6 +23,7 @@ This file documents the platform's features, stack, and stories. Feature entries - [Stashed drafts](#stashed-drafts) - [Images and descriptions](#images-and-descriptions) - [Links, link cards, and embedded players](#links-link-cards-and-embedded-players) + - [Repository pages](#repository-pages) - [Replies and threads](#replies-and-threads) - [Quoting commits](#quoting-commits) - [Polls and voting](#polls-and-voting) @@ -244,6 +245,14 @@ Supported links can also show click-to-play embedded players: YouTube, Vimeo, Lo Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx). +### Repository pages + +Every public GitHub repository has a page on /dev/null at `/github/{owner}/{repo}`, such as `/github/laravel/framework`. It gathers every commit and reply that links to the repository, or to anything in it like an issue, a pull request, or a file, newest first, so you can read what developers here are saying about a project in one place. A `.git` clone URL counts too. Only the link counts: writing `owner/repo` without `github.com` doesn't, and a link to a similarly named repository like `laravel/framework-docs` doesn't either. Names match regardless of case. + +The page shows the repository's name, description, and star count as GitHub reports them, refreshed about once a day, plus how many of the commits you can see mention it and how many people wrote them. Private repositories, deleted ones, or a GitHub outage leave out those details but still list the commits. The page follows the usual visibility rules: guests and crawlers can read it, only approved commits show to others, and muted and blocked people are filtered out. For now the page is reached by its address; links in commits still open GitHub. + +Sources: [repository page](app/Http/Controllers/RepositoryController.php), [matching commits](app/Queries/RepositoryCommitsQuery.php), [GitHub details](app/Queries/GitHubRepositoryQuery.php), [page](resources/js/pages/repository/show.tsx). + ### Replies and threads Reply to a visible commit to continue its thread. Replies use the same text limits, image support, moderation, and submission rate limit as commits. Threads expand inline, a few replies at a time: choose 1, 3, 5, 10, 15, 20, or 25 in Settings → Appearance (3 by default). Opening a commit's own page shows its ancestors and replies. Quotes of a commit are listed among its replies and counted with them. Profiles have a separate Replies tab for authored replies. A reply cannot also be a quote, contain a poll, or be scheduled. diff --git a/app/Http/Controllers/RepositoryController.php b/app/Http/Controllers/RepositoryController.php new file mode 100644 index 000000000..f0ecb7a18 --- /dev/null +++ b/app/Http/Controllers/RepositoryController.php @@ -0,0 +1,29 @@ + $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)), + ]); + } +} diff --git a/app/Queries/GitHubRepositoryQuery.php b/app/Queries/GitHubRepositoryQuery.php new file mode 100644 index 000000000..ca8380152 --- /dev/null +++ b/app/Queries/GitHubRepositoryQuery.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/app/Queries/RepositoryCommitsQuery.php b/app/Queries/RepositoryCommitsQuery.php new file mode 100644 index 000000000..50f6fccfb --- /dev/null +++ b/app/Queries/RepositoryCommitsQuery.php @@ -0,0 +1,53 @@ +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]); + } +} diff --git a/database/migrations/2026_10_26_120000_add_trigram_index_to_body_of_commits_table.php b/database/migrations/2026_10_26_120000_add_trigram_index_to_body_of_commits_table.php new file mode 100644 index 000000000..afbd55cd7 --- /dev/null +++ b/database/migrations/2026_10_26_120000_add_trigram_index_to_body_of_commits_table.php @@ -0,0 +1,22 @@ +rawIndex('body gin_trgm_ops', 'commits_body_trigram_index')->algorithm('gin'); + }); + } +}; diff --git a/resources/js/pages/repository/show.tsx b/resources/js/pages/repository/show.tsx new file mode 100644 index 000000000..9b7722575 --- /dev/null +++ b/resources/js/pages/repository/show.tsx @@ -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; +}; + +export default function Show({ owner, repo, github, stats, commits }: Props) { + const name = github?.name ?? `${owner}/${repo}`; + + return ( + <> + + +
+

+ + + {name} + +

+ + {github?.description && ( +

+ {github.description} +

+ )} + +

+ {github && ( + + + {github.stars.toLocaleString()} + + )} + {stats.commits > 0 && ( + + {stats.commits} commit + {stats.commits === 1 ? '' : 's'} by{' '} + {stats.people === 1 + ? '1 person' + : `${stats.people} people`} + + )} +

+
+ + + Link to it in a commit and it shows up here. + + } + /> + + ); +} + +Show.layout = AppLayout; diff --git a/routes/web.php b/routes/web.php index 19685915d..7f8aff455 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,6 +87,7 @@ use App\Http\Controllers\PushSubscriptionController; use App\Http\Controllers\RemoteCandidateController; use App\Http\Controllers\ReportedCommitController; +use App\Http\Controllers\RepositoryController; use App\Http\Controllers\SafetyController; use App\Http\Controllers\ScheduledCommitController; use App\Http\Controllers\SearchController; @@ -198,6 +199,9 @@ ->scopeBindings() ->whereNumber('photo') ->name('commit-photo.show'); + Route::get('github/{owner}/{repository}', [RepositoryController::class, 'show']) + ->where(['owner' => '[A-Za-z0-9-]{1,39}', 'repository' => '(?!\.+$)[A-Za-z0-9._-]{1,100}']) + ->name('repository.show'); }); // Sitemaps... diff --git a/tests/Feature/Controllers/RepositoryControllerTest.php b/tests/Feature/Controllers/RepositoryControllerTest.php new file mode 100644 index 000000000..8dc372bc6 --- /dev/null +++ b/tests/Feature/Controllers/RepositoryControllerTest.php @@ -0,0 +1,90 @@ + 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(); +});