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 (
+ <>
+
+
+
+
+
+ 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();
+});