From 0dffb82b095e6cae937438733dabdf9d4cf2f46e Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 01/25] test: add embeddingAtDistance() for vectors at a known cosine distance A unit vector in the plane of axes 0 and 1 whose distance to the one-hot vector at index 0 is exactly the given value. Signed-off-by: Younes --- tests/Pest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Pest.php b/tests/Pest.php index 96636a74a..51c8d4b1f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -61,6 +61,18 @@ function embeddingVector(int $dimension): array return $vector; } +/** + * @return list + */ +function embeddingAtDistance(float $distance): array +{ + $vector = array_fill(0, OpenAI::EMBEDDING_DIMENSIONS, 0.0); + $vector[0] = 1 - $distance; + $vector[1] = sqrt(1 - (1 - $distance) ** 2); + + return $vector; +} + /** * @param list> $embeddings * @return array{data: list}>} From 9cc891cbc259aa7e0020a294dce5048f456f9fd8 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 02/25] feat: query the nearest embedded commits to a commit RelatedCommitsQuery returns up to three top-level commits from other threads whose embedding is within 0.45 cosine distance of the given commit, nearest first, scoped like search by visibility, mutes and blocks. The source vector is read in a subselect so it never leaves the database. Signed-off-by: Younes --- app/Queries/RelatedCommitsQuery.php | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 app/Queries/RelatedCommitsQuery.php diff --git a/app/Queries/RelatedCommitsQuery.php b/app/Queries/RelatedCommitsQuery.php new file mode 100644 index 000000000..a4de43d06 --- /dev/null +++ b/app/Queries/RelatedCommitsQuery.php @@ -0,0 +1,48 @@ + + */ + public function get(Commit $commit, ?User $viewer): Collection + { + $distance = 'commits.embedding <=> (select source.embedding from commits as source where source.id = ?)'; + + return Commit::query() + ->topLevel() + ->whereKeyNot($commit->threadRootId()) + ->visibleTo($viewer) + ->withoutMutedAuthorsFor($viewer) + ->whereRaw("({$distance}) <= ?", [$commit->id, self::MAX_DISTANCE]) + ->withVisibleReplyCount($viewer) + ->withContinuationId($viewer) + ->withCardDataFor($viewer) + ->orderByRaw($distance, [$commit->id]) + ->limit(self::LIMIT) + ->get() + ->toBase(); + } +} From e5b28d3049c7c71c9a30792f052327fcdad41418 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 03/25] test: related commits are the nearest top-level commits of other threads Signed-off-by: Younes --- .../Unit/Queries/RelatedCommitsQueryTest.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/Unit/Queries/RelatedCommitsQueryTest.php diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php new file mode 100644 index 000000000..f594df481 --- /dev/null +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -0,0 +1,50 @@ + + */ +function relatedCommitIds(Commit $commit, ?User $viewer = null): array +{ + return resolve(RelatedCommitsQuery::class)->get($commit, $viewer)->pluck('id')->all(); +} + +it('lists the nearest top-level commits, nearest first', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $farther = Commit::factory()->create(['embedding' => embeddingAtDistance(0.3)]); + $nearest = Commit::factory()->create(['embedding' => embeddingAtDistance(0.1)]); + + expect(relatedCommitIds($source))->toBe([$nearest->id, $farther->id]); +}); + +it('never lists the thread being read', function (): void { + $root = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $reply = Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(0)]); + Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(0)]); + $other = Commit::factory()->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($reply))->toBe([$other->id]) + ->and(relatedCommitIds($root))->toBe([$other->id]); +}); + +it("uses a reply's own vector as the question", function (): void { + $root = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $reply = Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(1)]); + Commit::factory()->create(['embedding' => embeddingVector(0)]); + $closeToReply = Commit::factory()->create(['embedding' => embeddingVector(1)]); + + expect(relatedCommitIds($reply))->toBe([$closeToReply->id]); +}); + +it('leaves replies out', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $otherRoot = Commit::factory()->create(['embedding' => embeddingVector(1)]); + Commit::factory()->for($otherRoot, 'parent')->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source))->toBeEmpty(); +}); From 949d8178b3196a92929a1a3ef992b00c66899e21 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 04/25] test: related commits stop at the distance cutoff and cap at three Signed-off-by: Younes --- .../Unit/Queries/RelatedCommitsQueryTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php index f594df481..5b066fc10 100644 --- a/tests/Unit/Queries/RelatedCommitsQueryTest.php +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -48,3 +48,24 @@ function relatedCommitIds(Commit $commit, ?User $viewer = null): array expect(relatedCommitIds($source))->toBeEmpty(); }); + +it('drops commits past the distance cutoff', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingAtDistance(RelatedCommitsQuery::MAX_DISTANCE + 0.01)]); + + expect(relatedCommitIds($source))->toBeEmpty(); +}); + +it('includes a commit right at the cutoff', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $atCutoff = Commit::factory()->create(['embedding' => embeddingAtDistance(RelatedCommitsQuery::MAX_DISTANCE)]); + + expect(relatedCommitIds($source))->toBe([$atCutoff->id]); +}); + +it('caps the rail', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->count(RelatedCommitsQuery::LIMIT + 1)->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source))->toHaveCount(RelatedCommitsQuery::LIMIT); +}); From b270242a09c5f1e33e66f8256041da99f7aa90dc Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 05/25] test: related commits are empty without a source or candidate vector Signed-off-by: Younes --- .../Unit/Queries/RelatedCommitsQueryTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php index 5b066fc10..73228961c 100644 --- a/tests/Unit/Queries/RelatedCommitsQueryTest.php +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -2,9 +2,12 @@ declare(strict_types=1); +use App\Actions\DeleteCommit; +use App\Actions\DeleteCommitContent; use App\Models\Commit; use App\Models\User; use App\Queries\RelatedCommitsQuery; +use Illuminate\Support\Facades\Queue; /** * @return list @@ -69,3 +72,30 @@ function relatedCommitIds(Commit $commit, ?User $viewer = null): array expect(relatedCommitIds($source))->toHaveCount(RelatedCommitsQuery::LIMIT); }); + +it('returns nothing for a commit without an embedding', function (): void { + $source = Commit::factory()->create(['embedding' => null]); + Commit::factory()->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source))->toBeEmpty(); +}); + +it("returns nothing after the commit's content was erased", function (): void { + Queue::fake(); + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingVector(0)]); + + resolve(DeleteCommit::class)->handle($source); + $this->travel(30)->seconds(); + resolve(DeleteCommitContent::class)->handle($source); + + expect(relatedCommitIds($source))->toBeEmpty(); +}); + +it('skips candidates without an embedding', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $embedded = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => null]); + + expect(relatedCommitIds($source))->toBe([$embedded->id]); +}); From 59ac3501aaaab1deb1b473227ff51eccb9d1da4a Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 06/25] test: related commits hide what search hides from the viewer Signed-off-by: Younes --- .../Unit/Queries/RelatedCommitsQueryTest.php | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php index 73228961c..0aeeb00a4 100644 --- a/tests/Unit/Queries/RelatedCommitsQueryTest.php +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -99,3 +99,64 @@ function relatedCommitIds(Commit $commit, ?User $viewer = null): array expect(relatedCommitIds($source))->toBe([$embedded->id]); }); + +it('hides pending and rejected commits from other viewers', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $approved = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->rejected()->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source, User::factory()->create()))->toBe([$approved->id]); +}); + +it('shows an author their own pending commit', function (): void { + $author = User::factory()->create(); + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $pending = Commit::factory()->for($author)->pending()->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source, $author))->toBe([$pending->id]); +}); + +it('hides approved commits across a block in either direction', function (): void { + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $viewer->blocking()->attach($blocked); + $blocker->blocking()->attach($viewer); + + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $reachable = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->for($blocked)->create(['embedding' => embeddingVector(0)]); + Commit::factory()->for($blocker)->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source, $viewer))->toBe([$reachable->id]); +}); + +it('hides muted authors', function (): void { + $viewer = User::factory()->create(); + $muted = User::factory()->create(); + $viewer->muting()->attach($muted); + + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $reachable = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->for($muted)->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source, $viewer))->toBe([$reachable->id]); +}); + +it('hides deleted commits', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $kept = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingVector(0)])->delete(); + + expect(relatedCommitIds($source))->toBe([$kept->id]); +}); + +it('gives guests approved commits only', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $approved = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->rejected()->create(['embedding' => embeddingVector(0)]); + + expect(relatedCommitIds($source))->toBe([$approved->id]); +}); From ff42cb64e0971d73b3373e7e400ed4f75ac29c3c Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 07/25] test: related commits load card data in one query with no outside call Signed-off-by: Younes --- .../Unit/Queries/RelatedCommitsQueryTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php index 0aeeb00a4..64067244f 100644 --- a/tests/Unit/Queries/RelatedCommitsQueryTest.php +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -5,8 +5,11 @@ use App\Actions\DeleteCommit; use App\Actions\DeleteCommitContent; use App\Models\Commit; +use App\Models\Poll; use App\Models\User; use App\Queries\RelatedCommitsQuery; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; /** @@ -160,3 +163,41 @@ function relatedCommitIds(Commit $commit, ?User $viewer = null): array expect(relatedCommitIds($source))->toBe([$approved->id]); }); + +it('loads the commits as the feeds do, so the card renders them', function (): void { + $viewer = User::factory()->create(); + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $viewer->starredCommits()->attach($commit); + Poll::factory()->for($commit)->withOptions('Tabs', 'Spaces')->create(); + + $result = resolve(RelatedCommitsQuery::class)->get($source, $viewer)->sole(); + + expect($result->user->is($commit->user))->toBeTrue() + ->and($result->stars_count)->toBe(1) + ->and($result->starred)->toBeTrue() + ->and($result->replies_count)->toBe(0) + ->and($result->relationLoaded('poll'))->toBeTrue() + ->and($result->poll->options)->toHaveCount(2) + ->and($result->getAttributes())->not->toHaveKey('embedding'); +}); + +it('finds the candidates in one query', function (): void { + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingVector(0)]); + + DB::enableQueryLog(); + resolve(RelatedCommitsQuery::class)->get($source, null); + + expect(collect(DB::getQueryLog())->pluck('query')->filter(fn (string $query): bool => str_contains($query, '<=>')))->toHaveCount(1); +}); + +it('asks no outside service anything', function (): void { + Http::fake(); + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingVector(0)]); + + relatedCommitIds($source); + + Http::assertNothingSent(); +}); From 6b8009e696a8741bf2880d28fbcc433b1a6c8aea Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 08/25] feat: deny related commits as not found when the commit is not visible Signed-off-by: Younes --- .../Requests/ListRelatedCommitsRequest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/Http/Requests/ListRelatedCommitsRequest.php diff --git a/app/Http/Requests/ListRelatedCommitsRequest.php b/app/Http/Requests/ListRelatedCommitsRequest.php new file mode 100644 index 000000000..7959be05a --- /dev/null +++ b/app/Http/Requests/ListRelatedCommitsRequest.php @@ -0,0 +1,28 @@ +route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} From afb538a63dfdf90503f7470dc5e09729318d9261 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 09/25] feat: serve related commits at GET /commits/{commit}/related Returns {commits: Commit[]} through CommitResource so the rail renders the same cards as the feeds. Signed-off-by: Younes --- .../Controllers/RelatedCommitController.php | 20 +++++++++++++++++++ routes/web.php | 3 +++ 2 files changed, 23 insertions(+) create mode 100644 app/Http/Controllers/RelatedCommitController.php diff --git a/app/Http/Controllers/RelatedCommitController.php b/app/Http/Controllers/RelatedCommitController.php new file mode 100644 index 000000000..94d89094d --- /dev/null +++ b/app/Http/Controllers/RelatedCommitController.php @@ -0,0 +1,20 @@ +json(['commits' => $related->get($commit, $user)]); + } +} diff --git a/routes/web.php b/routes/web.php index 47f468dba..711d38094 100644 --- a/routes/web.php +++ b/routes/web.php @@ -72,6 +72,7 @@ use App\Http\Controllers\OrgTypingController; use App\Http\Controllers\PrivacyController; use App\Http\Controllers\ProductMetricsController; +use App\Http\Controllers\RelatedCommitController; use App\Http\Controllers\RemoteCandidateController; use App\Http\Controllers\ReportedCommitController; use App\Http\Controllers\SafetyController; @@ -218,6 +219,8 @@ ->name('commit-reply.index'); Route::get('commits/{commit}/continuation', [CommitContinuationController::class, 'index']) ->name('commit-continuation.index'); +Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index']) + ->name('related-commit.index'); // Search... Route::get('search', [SearchController::class, 'index']) From aa49e982fb24ad22a574c470dbf3332f4c162606 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 10/25] test: related commits endpoint answers visible commits only, without vectors Signed-off-by: Younes --- .../RelatedCommitControllerTest.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/Feature/Controllers/RelatedCommitControllerTest.php diff --git a/tests/Feature/Controllers/RelatedCommitControllerTest.php b/tests/Feature/Controllers/RelatedCommitControllerTest.php new file mode 100644 index 000000000..f845dace5 --- /dev/null +++ b/tests/Feature/Controllers/RelatedCommitControllerTest.php @@ -0,0 +1,57 @@ +create(); + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $related = Commit::factory()->create(['embedding' => embeddingVector(0)]); + + $this->actingAs($viewer) + ->getJson(route('related-commit.index', $commit)) + ->assertOk() + ->assertJsonCount(1, 'commits') + ->assertJsonPath('commits.0.id', $related->id) + ->assertJsonPath('commits.0.user.username', $related->user->username) + ->assertJsonMissingPath('commits.0.embedding'); +}); + +it('lets guests see the related commits of an approved commit', function (): void { + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $related = Commit::factory()->create(['embedding' => embeddingVector(0)]); + + $this->getJson(route('related-commit.index', $commit)) + ->assertOk() + ->assertJsonPath('commits.0.id', $related->id); +}); + +it('does not answer for a commit that is not visible', function (): void { + $commit = Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]); + + $this->actingAs(User::factory()->create()) + ->getJson(route('related-commit.index', $commit)) + ->assertNotFound(); +}); + +it('does not answer across a block', function (): void { + $viewer = User::factory()->create(); + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $viewer->blocking()->attach($commit->user); + + $this->actingAs($viewer) + ->getJson(route('related-commit.index', $commit)) + ->assertNotFound(); +}); + +it('does not answer for a deleted commit', function (): void { + Queue::fake(); + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + resolve(DeleteCommit::class)->handle($commit); + + $this->getJson(route('related-commit.index', $commit))->assertNotFound(); +}); From a7e188738b21f7fde9118e02d7ec9e76097f4b65 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 11/25] feat: add the signed API request for related commits Signed-off-by: Younes --- .../Requests/ListApiRelatedCommitsRequest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/Http/Requests/ListApiRelatedCommitsRequest.php diff --git a/app/Http/Requests/ListApiRelatedCommitsRequest.php b/app/Http/Requests/ListApiRelatedCommitsRequest.php new file mode 100644 index 000000000..e680f0f8c --- /dev/null +++ b/app/Http/Requests/ListApiRelatedCommitsRequest.php @@ -0,0 +1,28 @@ +route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} From b5875f8daad9d134528d920b87977559a131c2c4 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 12/25] feat: serve related commits at GET /api/v1/commits/{commit}/related Signed-off-by: Younes --- .../Api/RelatedCommitController.php | 21 +++++++++++++++++++ routes/api.php | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 app/Http/Controllers/Api/RelatedCommitController.php diff --git a/app/Http/Controllers/Api/RelatedCommitController.php b/app/Http/Controllers/Api/RelatedCommitController.php new file mode 100644 index 000000000..965f07584 --- /dev/null +++ b/app/Http/Controllers/Api/RelatedCommitController.php @@ -0,0 +1,21 @@ +get($commit, $user)); + } +} diff --git a/routes/api.php b/routes/api.php index c7c57ea7f..222469746 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,6 +32,7 @@ use App\Http\Controllers\Api\OrgMuteController; use App\Http\Controllers\Api\OrgReadController; use App\Http\Controllers\Api\OrgTypingController; +use App\Http\Controllers\Api\RelatedCommitController; use App\Http\Controllers\Api\RemoteCandidateController; use App\Http\Controllers\Api\StarredCommitController; use App\Http\Controllers\Api\UserBlockController; @@ -78,6 +79,8 @@ ->name('api-user-following.index'); Route::get('commits/{commit}', [ApiCommitController::class, 'show']) ->name('api-commit.show'); + Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index']) + ->name('api-related-commit.index'); }); // Signing in: the app redeems the code it got on the web, signed with a key that isn't approved yet... From 881c04aa67838b3b885437cd0991805d4f7c4e33 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 13/25] test: API related commits carry the signed viewer's stars and hide invisible commits Signed-off-by: Younes --- .../Api/RelatedCommitControllerTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/Feature/Controllers/Api/RelatedCommitControllerTest.php diff --git a/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php new file mode 100644 index 000000000..347500387 --- /dev/null +++ b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php @@ -0,0 +1,38 @@ +create(['embedding' => embeddingVector(0)]); + $related = Commit::factory()->create(['embedding' => embeddingVector(0)]); + + $this->getJson(route('api-related-commit.index', $commit)) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $related->id) + ->assertJsonPath('data.0.author.username', $related->user->username) + ->assertJsonMissingPath('data.0.starred') + ->assertJsonMissingPath('data.0.embedding'); +}); + +it('shows a signed request whether it starred each related commit', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $related = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $related->stars()->attach($user); + + signedApiRequest($keyPair, 'GET', route('api-related-commit.index', $commit)) + ->assertOk() + ->assertJsonPath('data.0.id', $related->id) + ->assertJsonPath('data.0.starred', true); +}); + +it('does not answer for a commit that is not visible', function (): void { + $commit = Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]); + + $this->getJson(route('api-related-commit.index', $commit))->assertNotFound(); +}); From be816435d5b628b7d33223b7b1bb915abfafef63 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 14/25] feat: type the related commits response Signed-off-by: Younes --- resources/js/types/commit.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 8af6a4e68..12790e4d3 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -132,6 +132,10 @@ export type ThreadContinuation = { next_id: string | null; }; +export type RelatedCommits = { + commits: Commit[]; +}; + export type CursorPaginated = { data: T[]; next_cursor: string | null; From 190f399585ecf27c7f17b7ce83eb42f7253d17ec Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 15/25] feat: fetchRelatedCommits() reads the rail and treats failures as empty Takes the url rather than the commit id so the node test runner, which cannot resolve Wayfinder's imports, can load it. Rethrows only an abort, so a stale response never lands on a newer page. Signed-off-by: Younes --- resources/js/lib/related-commits.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 resources/js/lib/related-commits.ts diff --git a/resources/js/lib/related-commits.ts b/resources/js/lib/related-commits.ts new file mode 100644 index 000000000..669390596 --- /dev/null +++ b/resources/js/lib/related-commits.ts @@ -0,0 +1,29 @@ +import type { Commit, RelatedCommits } from '@/types'; + +/** + * Takes the url rather than the commit id so the node test runner, which cannot resolve Wayfinder's imports, can load it. + */ +export function fetchRelatedCommits( + url: string, + signal: AbortSignal, +): Promise { + return fetch(url, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + signal, + }) + .then((response) => + response.ok + ? response.json().then((data: RelatedCommits) => data.commits) + : [], + ) + .catch((error: unknown) => { + if (signal.aborted) { + throw error; + } + + return []; + }); +} From b84ebcb33679980dc2a3a8fd26a58f9a98345ecd Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:40 +0200 Subject: [PATCH 16/25] test: fetchRelatedCommits() on success, 429, network failure and abort Signed-off-by: Younes --- tests/Unit/RelatedCommits.test.ts | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/Unit/RelatedCommits.test.ts diff --git a/tests/Unit/RelatedCommits.test.ts b/tests/Unit/RelatedCommits.test.ts new file mode 100644 index 000000000..0295e6a2b --- /dev/null +++ b/tests/Unit/RelatedCommits.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { fetchRelatedCommits } from '../../resources/js/lib/related-commits.ts'; +import type { Commit } from '../../resources/js/types'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function respondWith(response: () => Promise): void { + globalThis.fetch = () => response(); +} + +void test('returns the commits of the response', async () => { + const commits = [{ id: 'related' }] as Commit[]; + respondWith(() => Promise.resolve(Response.json({ commits }))); + + assert.deepEqual( + await fetchRelatedCommits('/commits/a/related', new AbortController().signal), + commits, + ); +}); + +void test('returns an empty list when the server asks to slow down', async () => { + respondWith(() => Promise.resolve(new Response(null, { status: 429 }))); + + assert.deepEqual( + await fetchRelatedCommits('/commits/a/related', new AbortController().signal), + [], + ); +}); + +void test('returns an empty list when the network fails', async () => { + respondWith(() => Promise.reject(new TypeError('Failed to fetch'))); + + assert.deepEqual( + await fetchRelatedCommits('/commits/a/related', new AbortController().signal), + [], + ); +}); + +void test('rejects when the request was aborted, so a stale response never lands', async () => { + const controller = new AbortController(); + controller.abort(); + respondWith(() => Promise.reject(new DOMException('Aborted', 'AbortError'))); + + await assert.rejects( + fetchRelatedCommits('/commits/a/related', controller.signal), + { name: 'AbortError' }, + ); +}); From 333abf3e11dbe68105296c24b6191f2d759b33a0 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:41 +0200 Subject: [PATCH 17/25] feat: useRelatedCommits() fetches the rail after the page renders Aborts the request when the commit changes and returns only commits loaded for the current commit. Signed-off-by: Younes --- resources/js/hooks/use-related-commits.ts | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 resources/js/hooks/use-related-commits.ts diff --git a/resources/js/hooks/use-related-commits.ts b/resources/js/hooks/use-related-commits.ts new file mode 100644 index 000000000..557da8399 --- /dev/null +++ b/resources/js/hooks/use-related-commits.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import RelatedCommitController from '@/actions/App/Http/Controllers/RelatedCommitController'; +import { fetchRelatedCommits } from '@/lib/related-commits'; +import type { Commit } from '@/types'; + +export function useRelatedCommits(commitId: string): Commit[] { + const [loaded, setLoaded] = useState<{ id: string; commits: Commit[] }>({ + id: '', + commits: [], + }); + + useEffect(() => { + const controller = new AbortController(); + + fetchRelatedCommits( + RelatedCommitController.index(commitId).url, + controller.signal, + ) + .then((commits) => setLoaded({ id: commitId, commits })) + .catch(() => {}); + + return () => controller.abort(); + }, [commitId]); + + return loaded.id === commitId ? loaded.commits : []; +} From 01798ccecef4a42cee3e73d31450fb13f5ffafc6 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:41 +0200 Subject: [PATCH 18/25] feat: RelatedCommits rail renders neighbours as commit cards Ordinary CommitCards, so j and k walk from the thread into the rail. Renders nothing when the list is empty. Signed-off-by: Younes --- resources/js/components/related-commits.tsx | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 resources/js/components/related-commits.tsx diff --git a/resources/js/components/related-commits.tsx b/resources/js/components/related-commits.tsx new file mode 100644 index 000000000..fe94f6583 --- /dev/null +++ b/resources/js/components/related-commits.tsx @@ -0,0 +1,31 @@ +import { CommitCard } from '@/components/commit-card'; +import { useRelatedCommits } from '@/hooks/use-related-commits'; +import { laneOf } from '@/lib/utils'; + +export function RelatedCommits({ commitId }: { commitId: string }) { + const commits = useRelatedCommits(commitId); + + if (commits.length === 0) { + return null; + } + + return ( +
+
+ +
+ + {commits.map((commit, index) => ( + + ))} +
+ ); +} From cfa9b4ac3818cd8ff5b6f771fba8cf09c257e358 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:41 +0200 Subject: [PATCH 19/25] feat: show the related commits rail under a thread Mounted after the graph for every commit that is not deleted. Signed-off-by: Younes --- resources/js/pages/commit/show.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/js/pages/commit/show.tsx b/resources/js/pages/commit/show.tsx index a2e8f6134..1daf2f57d 100644 --- a/resources/js/pages/commit/show.tsx +++ b/resources/js/pages/commit/show.tsx @@ -4,6 +4,7 @@ import LogController from '@/actions/App/Http/Controllers/LogController'; import { CommitCard, CommitTombstone } from '@/components/commit-card'; import { CopyLinkButton } from '@/components/copy-link-button'; import { GraphCanvas } from '@/components/graph-canvas'; +import { RelatedCommits } from '@/components/related-commits'; import { useContinuation } from '@/hooks/use-continuation'; import AppLayout from '@/layouts/app-layout'; import { isDeleted, laneOf, shortHash } from '@/lib/utils'; @@ -164,6 +165,8 @@ export default function Show({ )} + + {!isDeleted(commit) && } ); } From c271bac24d1a38a99755d1d140c0770a6ca8289b Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 21:38:41 +0200 Subject: [PATCH 20/25] docs: describe the related commits rail in the knowledge base Signed-off-by: Younes --- KNOWLEDGE_BASE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d81d01de5..d2e476ebd 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -249,6 +249,12 @@ Deleting a parent does not automatically delete other people's replies: an autho Sources: [reply validation](app/Http/Requests/CreateCommitReplyRequest.php), [commit page](app/Http/Controllers/CommitController.php), [reply notifications](app/Actions/CreateReplyNotifications.php), [commit deletion](app/Actions/DeleteCommit.php). +### Related commits + +Under a commit, /dev/null shows up to three commits from other threads that say something close to it. It only suggests commits you could find through search: nothing from people you block, who block you or whom you mute, and nothing still pending. When nothing is close, nothing shows. + +Sources: [related commits query](app/Queries/RelatedCommitsQuery.php), [related commits rail](resources/js/components/related-commits.tsx). + ### Quoting commits Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has no preview, link-card choice, poll, or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. From e1bf19d8783fc3b0503a9346cd6a6aaec76656a1 Mon Sep 17 00:00:00 2001 From: Younes Date: Fri, 23 Oct 2026 22:08:59 +0200 Subject: [PATCH 21/25] feat: raise the related commits cutoff to 0.60 after measuring real posts On 281 real posts embedded with text-embedding-3-small, every pair under 0.45 is a near duplicate, so the rail stayed empty on most pages. Pairs between 0.45 and 0.60 share a topic without repeating each other, and past 0.60 they come apart. At 0.45, 16 of 230 top-level posts had a rail. At 0.60, 170 do. Signed-off-by: Younes --- app/Queries/RelatedCommitsQuery.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Queries/RelatedCommitsQuery.php b/app/Queries/RelatedCommitsQuery.php index a4de43d06..9045f39ff 100644 --- a/app/Queries/RelatedCommitsQuery.php +++ b/app/Queries/RelatedCommitsQuery.php @@ -11,9 +11,10 @@ final readonly class RelatedCommitsQuery { /** - * Cosine distance up to which two text-embedding-3-small vectors say about the same thing. + * Cosine distance up to which two text-embedding-3-small vectors say about the same thing. Measured on 281 real + * posts: pairs under 0.45 are near duplicates, 0.45 to 0.60 share a topic, and past 0.60 the pairs come apart. */ - public const float MAX_DISTANCE = 0.45; + public const float MAX_DISTANCE = 0.60; /** * Far under the HNSW index's default ef_search of 40, so filtering its candidates by visibility rarely starves the rail. From fc0e598e2f676aa37305133c2b81a660083763aa Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 27 Oct 2026 09:42:17 +0100 Subject: [PATCH 22/25] test: assert the related commit author by handle Production moved the GitHub identity to profiles, so UserResource exposes handle instead of username. Signed-off-by: Younes --- tests/Feature/Controllers/Api/RelatedCommitControllerTest.php | 2 +- tests/Feature/Controllers/RelatedCommitControllerTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php index 347500387..848d15d08 100644 --- a/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php @@ -13,7 +13,7 @@ ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $related->id) - ->assertJsonPath('data.0.author.username', $related->user->username) + ->assertJsonPath('data.0.author.handle', $related->user->handle) ->assertJsonMissingPath('data.0.starred') ->assertJsonMissingPath('data.0.embedding'); }); diff --git a/tests/Feature/Controllers/RelatedCommitControllerTest.php b/tests/Feature/Controllers/RelatedCommitControllerTest.php index f845dace5..cd7b093af 100644 --- a/tests/Feature/Controllers/RelatedCommitControllerTest.php +++ b/tests/Feature/Controllers/RelatedCommitControllerTest.php @@ -17,7 +17,7 @@ ->assertOk() ->assertJsonCount(1, 'commits') ->assertJsonPath('commits.0.id', $related->id) - ->assertJsonPath('commits.0.user.username', $related->user->username) + ->assertJsonPath('commits.0.user.handle', $related->user->handle) ->assertJsonMissingPath('commits.0.embedding'); }); From 7fcc681c161eb69aca375b84461237f2e2ae6d44 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 27 Oct 2026 09:42:17 +0100 Subject: [PATCH 23/25] fix: keep scanning the vector index until related commits pass the filters The HNSW index returns a fixed number of candidates before the filters run, so closer replies or hidden commits could use them all up and leave the rail empty. An iterative scan keeps reading the index until LIMIT commits pass. Needs pgvector 0.8 or later. Signed-off-by: Younes --- app/Queries/RelatedCommitsQuery.php | 41 ++++++++++++------- .../Unit/Queries/RelatedCommitsQueryTest.php | 13 ++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/Queries/RelatedCommitsQuery.php b/app/Queries/RelatedCommitsQuery.php index 9045f39ff..d22f12e9f 100644 --- a/app/Queries/RelatedCommitsQuery.php +++ b/app/Queries/RelatedCommitsQuery.php @@ -7,6 +7,7 @@ use App\Models\Commit; use App\Models\User; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; final readonly class RelatedCommitsQuery { @@ -16,9 +17,6 @@ */ public const float MAX_DISTANCE = 0.60; - /** - * Far under the HNSW index's default ef_search of 40, so filtering its candidates by visibility rarely starves the rail. - */ public const int LIMIT = 3; /** @@ -26,24 +24,39 @@ * them so the same card renders them. Only commits the viewer could find through search are suggested, and none * further than MAX_DISTANCE, so a commit with nothing close, or without an embedding, gets an empty list. * + * The HNSW index hands back a fixed number of candidates before the filters run, so closer replies or hidden + * commits could use them all up. An iterative scan keeps reading the index until LIMIT commits pass the filters. + * The cutoff is applied to those afterwards, or a commit with nothing close would scan the whole index. + * * @return Collection */ public function get(Commit $commit, ?User $viewer): Collection { - $distance = 'commits.embedding <=> (select source.embedding from commits as source where source.id = ?)'; - - return Commit::query() + $nearest = Commit::query() + ->select('commits.id') + ->selectRaw( + 'commits.embedding <=> (select source.embedding from commits as source where source.id = ?) as distance', + [$commit->id], + ) ->topLevel() ->whereKeyNot($commit->threadRootId()) ->visibleTo($viewer) ->withoutMutedAuthorsFor($viewer) - ->whereRaw("({$distance}) <= ?", [$commit->id, self::MAX_DISTANCE]) - ->withVisibleReplyCount($viewer) - ->withContinuationId($viewer) - ->withCardDataFor($viewer) - ->orderByRaw($distance, [$commit->id]) - ->limit(self::LIMIT) - ->get() - ->toBase(); + ->orderBy('distance') + ->limit(self::LIMIT); + + return DB::transaction(function () use ($nearest, $viewer): Collection { + DB::statement('set local hnsw.iterative_scan = strict_order'); + + return Commit::query() + ->joinSub($nearest, 'nearest', 'nearest.id', '=', 'commits.id') + ->where('nearest.distance', '<=', self::MAX_DISTANCE) + ->withVisibleReplyCount($viewer) + ->withContinuationId($viewer) + ->withCardDataFor($viewer) + ->orderBy('nearest.distance') + ->get() + ->toBase(); + }); } } diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php index 64067244f..b0cc2366d 100644 --- a/tests/Unit/Queries/RelatedCommitsQueryTest.php +++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php @@ -55,6 +55,19 @@ function relatedCommitIds(Commit $commit, ?User $viewer = null): array expect(relatedCommitIds($source))->toBeEmpty(); }); +it('finds top-level commits through the vector index behind many closer replies', function (): void { + DB::statement('set local enable_sort = off'); + $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $otherRoot = Commit::factory()->create(['embedding' => embeddingVector(1)]); + foreach (range(1, 80) as $step) { + Commit::factory()->for($otherRoot, 'parent')->create(['embedding' => embeddingAtDistance($step / 1000)]); + } + + $related = Commit::factory()->count(3)->create(['embedding' => embeddingAtDistance(0.2)]); + + expect(relatedCommitIds($source))->toEqualCanonicalizing($related->pluck('id')->all()); +}); + it('drops commits past the distance cutoff', function (): void { $source = Commit::factory()->create(['embedding' => embeddingVector(0)]); Commit::factory()->create(['embedding' => embeddingAtDistance(RelatedCommitsQuery::MAX_DISTANCE + 0.01)]); From adb5b0d09b65c7fbcdab8a87db5c91d74e11a657 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 27 Oct 2026 09:42:17 +0100 Subject: [PATCH 24/25] fix: reopen a related commit when its delete is undone The rail hid a deleted card for the rest of the page view. It now listens to the delete and restore flashes, so Undo brings the card back open. Signed-off-by: Younes --- resources/js/hooks/use-related-commits.ts | 34 ++++++++++++++++++----- resources/js/lib/related-commits.ts | 25 +++++++++++++++++ tests/Unit/RelatedCommits.test.ts | 16 ++++++++++- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/resources/js/hooks/use-related-commits.ts b/resources/js/hooks/use-related-commits.ts index 557da8399..da751e194 100644 --- a/resources/js/hooks/use-related-commits.ts +++ b/resources/js/hooks/use-related-commits.ts @@ -1,13 +1,15 @@ +import { router } from '@inertiajs/react'; import { useEffect, useState } from 'react'; import RelatedCommitController from '@/actions/App/Http/Controllers/RelatedCommitController'; -import { fetchRelatedCommits } from '@/lib/related-commits'; +import { fetchRelatedCommits, hiddenAfterFlash } from '@/lib/related-commits'; import type { Commit } from '@/types'; export function useRelatedCommits(commitId: string): Commit[] { - const [loaded, setLoaded] = useState<{ id: string; commits: Commit[] }>({ - id: '', - commits: [], - }); + const [loaded, setLoaded] = useState<{ + id: string; + commits: Commit[]; + hidden: ReadonlySet; + }>({ id: '', commits: [], hidden: new Set() }); useEffect(() => { const controller = new AbortController(); @@ -16,11 +18,29 @@ export function useRelatedCommits(commitId: string): Commit[] { RelatedCommitController.index(commitId).url, controller.signal, ) - .then((commits) => setLoaded({ id: commitId, commits })) + .then((commits) => + setLoaded({ id: commitId, commits, hidden: new Set() }), + ) .catch(() => {}); return () => controller.abort(); }, [commitId]); - return loaded.id === commitId ? loaded.commits : []; + useEffect( + () => + router.on('flash', (event) => + setLoaded((current) => ({ + ...current, + hidden: hiddenAfterFlash( + current.hidden, + event.detail.flash, + ), + })), + ), + [], + ); + + return loaded.id === commitId + ? loaded.commits.filter((commit) => !loaded.hidden.has(commit.id)) + : []; } diff --git a/resources/js/lib/related-commits.ts b/resources/js/lib/related-commits.ts index 669390596..43d886bd0 100644 --- a/resources/js/lib/related-commits.ts +++ b/resources/js/lib/related-commits.ts @@ -27,3 +27,28 @@ export function fetchRelatedCommits( return []; }); } + +/** + * The rail keeps the order it was fetched in, so a deleted commit's card is hidden rather than dropped, and a restore + * mounts it afresh as the feeds do. + */ +export function hiddenAfterFlash( + hidden: ReadonlySet, + { + deleted, + restored, + }: { deleted?: { id: string }; restored?: { id: string } }, +): ReadonlySet { + if (deleted) { + return new Set(hidden).add(deleted.id); + } + + if (restored && hidden.has(restored.id)) { + const shown = new Set(hidden); + shown.delete(restored.id); + + return shown; + } + + return hidden; +} diff --git a/tests/Unit/RelatedCommits.test.ts b/tests/Unit/RelatedCommits.test.ts index 0295e6a2b..6f88986aa 100644 --- a/tests/Unit/RelatedCommits.test.ts +++ b/tests/Unit/RelatedCommits.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { fetchRelatedCommits } from '../../resources/js/lib/related-commits.ts'; +import { fetchRelatedCommits, hiddenAfterFlash } from '../../resources/js/lib/related-commits.ts'; import type { Commit } from '../../resources/js/types'; const realFetch = globalThis.fetch; @@ -51,3 +51,17 @@ void test('rejects when the request was aborted, so a stale response never lands { name: 'AbortError' }, ); }); + +void test('hides a deleted commit and shows it again once restored', () => { + const hidden = hiddenAfterFlash(new Set(), { deleted: { id: 'related' } }); + + assert.deepEqual([...hidden], ['related']); + assert.deepEqual([...hiddenAfterFlash(hidden, { restored: { id: 'related' } })], []); +}); + +void test('leaves the hidden commits alone on other flashes', () => { + const hidden = new Set(['related']); + + assert.equal(hiddenAfterFlash(hidden, {}), hidden); + assert.deepEqual([...hiddenAfterFlash(hidden, { restored: { id: 'other' } })], ['related']); +}); From d25243c9c0d77d2b84beef72e268f2653ab3d3f4 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 27 Oct 2026 10:35:26 +0100 Subject: [PATCH 25/25] fix: rate limit the related commits route Each commit page asks for its related commits, a vector index scan. The route gets its own budget of 60 a minute per user or guest IP, rather than the search limit, which is sized for paid embedding calls. Signed-off-by: Younes --- app/Providers/AppServiceProvider.php | 5 +++++ routes/web.php | 1 + .../Controllers/RelatedCommitControllerTest.php | 16 ++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 87bd21f7f..90808d042 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -153,6 +153,11 @@ private function bootRateLimitingDefaults(): void $headers, ))); + // Every commit page asks for its related commits, a vector index scan with no outside call... + RateLimiter::for('related-commits', fn (Request $request): Limit => Limit::perMinute(60)->by( + $request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(), + )); + // Exports stream every commit a user ever made... RateLimiter::for('exports', fn (Request $request): Limit => Limit::perHour(3)->by($this->userId($request))); } diff --git a/routes/web.php b/routes/web.php index d6a81a95f..967683c04 100644 --- a/routes/web.php +++ b/routes/web.php @@ -256,6 +256,7 @@ Route::get('commits/{commit}/continuation', [CommitContinuationController::class, 'index']) ->name('commit-continuation.index'); Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index']) + ->middleware('throttle:related-commits') ->name('related-commit.index'); // Search... diff --git a/tests/Feature/Controllers/RelatedCommitControllerTest.php b/tests/Feature/Controllers/RelatedCommitControllerTest.php index cd7b093af..87ccfe57d 100644 --- a/tests/Feature/Controllers/RelatedCommitControllerTest.php +++ b/tests/Feature/Controllers/RelatedCommitControllerTest.php @@ -55,3 +55,19 @@ $this->getJson(route('related-commit.index', $commit))->assertNotFound(); }); + +it('limits each guest IP without limiting other visitors', function (): void { + $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); + + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.1']); + + foreach (range(1, 60) as $attempt) { + $this->getJson(route('related-commit.index', $commit))->assertOk(); + } + + $this->getJson(route('related-commit.index', $commit))->assertTooManyRequests(); + + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.2']); + + $this->getJson(route('related-commit.index', $commit))->assertOk(); +});