Skip to content
Open
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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ An app can use the same `/api/v1` for you with signed requests. It keeps an SSH

A signed request sees what the web shows you, such as your own pending commits. It can also, with the web's validation, moderation, and rate limits:

- read your account, logs, replies, notifications (including grouped activity), and starred commits;
- read your account, logs, replies, threads authors write as replies to themselves, notifications (including grouped activity), and starred commits;
- commit, reply, and quote, with images, and delete your commits, with the same undo window as the web;
- preview commit drafts without publishing them;
- follow or unfollow people and organizations, mute or unmute, and block or unblock, and list who you muted and blocked;
Expand Down
48 changes: 48 additions & 0 deletions app/Http/Controllers/Api/CommitContinuationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\ListApiCommitContinuationRequest;
use App\Http\Resources\CommitResource;
use App\Models\Commit;
use App\Models\User;
use App\Queries\CommitContinuationQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\CursorPaginator;

final readonly class CommitContinuationController
{
/**
* The commits that follow the commit in its author's thread, as the web reads it, paged like its replies: the
* cursor continues the commit's thread from the last commit of a page. position is the place in the thread of the
* commit a page continues from and length how many commits follow it, so position + length is the size of the
* thread, and is_capped says it goes on past that.
*/
public function index(ListApiCommitContinuationRequest $request, #[CurrentUser] User $user, Commit $commit, CommitContinuationQuery $continuation): AnonymousResourceCollection
{
$thread = $continuation->get($request->continuedCommit() ?? $commit, $user);
$commits = $thread->commits->values();
$nextIds = $commits->skip(1)->pluck('id')->push($thread->nextId)->values();

foreach ($commits as $index => $listed) {
$listed->setAttribute('continuation_id', $nextIds[$index]);
$listed->setAttribute('thread_id', $commit->id);
}

// The paginator only links to a next page when it is given one item more than a page.
$page = new CursorPaginator(
$thread->nextId === null ? $commits : $commits->concat([new Commit]),
CommitContinuationQuery::PER_PAGE,
options: ['path' => $request->url(), 'parameters' => ['thread_id', 'id']],
);

return CommitResource::collection($page)->additional(['meta' => [
'position' => $thread->position,
'length' => $thread->length,
'is_capped' => $thread->isCapped,
]]);
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/CommitReplyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
{
public function index(ListApiCommitRepliesRequest $request, #[CurrentUser] User $user, Commit $commit, CommitRepliesQuery $replies): AnonymousResourceCollection
{
return CommitResource::collection($replies->paginate($commit, $user, $request->boolean('newest'))->withQueryString());
return CommitResource::collection($replies->paginate($commit, $user, $request->boolean('newest'), except: $request->exceptedReplyId())->withQueryString());
}
}
67 changes: 67 additions & 0 deletions app/Http/Requests/ListApiCommitContinuationRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Commit;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Str;

final class ListApiCommitContinuationRequest extends FormRequest
{
private ?Commit $continued = null;

/**
* The cursor of a page after the first continues the thread from the last commit of the page before, so the user
* must still see that commit too, and it only continues the thread of the commit it was given for.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$commit = $this->route('commit');
assert($commit instanceof Commit);

return $commit->isVisibleTo($user) && $this->continuedCommit()?->isVisibleTo($user)
? Response::allow()
: Response::denyAsNotFound();
}

/**
* The commit whose next commits this page lists: the commit itself, or the one the cursor continues from.
*/
public function continuedCommit(): ?Commit
{
if ($this->continued instanceof Commit) {
return $this->continued;
}

$commit = $this->route('commit');
assert($commit instanceof Commit);

$cursor = $this->query('cursor');
$parameters = is_string($cursor) ? Cursor::fromEncoded($cursor)?->toArray() ?? [] : [];
$id = $parameters['id'] ?? null;

if ($id === null) {
return $this->continued = $commit;
}

return $this->continued = ($parameters['thread_id'] ?? null) === $commit->id && is_string($id) && Str::isUuid($id)
? Commit::query()->find($id)
: null;
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
9 changes: 9 additions & 0 deletions app/Http/Requests/ListApiCommitRepliesRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ public function authorize(): Response
return $commit->isVisibleTo($user) ? Response::allow() : Response::denyAsNotFound();
}

/**
* The reply the client shows elsewhere, such as the continuation of the commit, as on the web.
*/
public function exceptedReplyId(): ?string
{
return $this->string('except')->value() ?: null;
}

/**
* Newest first continues the next_cursor of a reply preview, as on the web.
*
Expand All @@ -34,6 +42,7 @@ public function rules(): array
{
return [
'newest' => ['sometimes', 'boolean'],
'except' => ['sometimes', 'nullable', 'uuid'],
];
}
}
12 changes: 8 additions & 4 deletions app/Http/Resources/CommitResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@
* and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. Like the web's cards,
* it also has preview_segments, the excerpt a list shows of a long commit, the poll, with what the user may see of
* its votes, and the commit it quotes, or null once the user may no longer see that one, while quoted_commit_id still
* says it quoted something. In a list, reply_preview is the newest reply or quote the web shows under it. As on the
* web, the counts, link_preview, images, starred, parent, poll, quoted_commit and reply_preview are only
* there when they were loaded, so ancestors and the commits of notifications come without some of them. The images of
* a commit that isn't published yet have temporary URLs, since the app has no web session to load them with.
* says it quoted something. In a list, reply_preview is the newest reply or quote the web shows under it.
* continuation_id is the author's earliest reply to their own commit, which the web shows as the next commit of their
* thread rather than among the replies, so replies_count leaves it out; it is null when there is none the user may see.
* As on the web, the counts, link_preview, images, starred, continuation_id, parent, poll, quoted_commit and
* reply_preview are only there when they were loaded, so ancestors and the commits of notifications come without some
* of them. The images of a commit that isn't published yet have temporary URLs, since the app has no web session to
* load them with.
*
* A deleted commit that still has replies stays in its thread as a tombstone: its id, parent_id, deleted_at and
* replies_count, but never its author or body. Only signed requests are ever sent one.
Expand Down Expand Up @@ -81,6 +84,7 @@ public function toArray(Request $request): array
'starred' => $this->whenHas('starred'),
'pasteis_count' => $this->pasteis_count,
'deleted_at' => null,
'continuation_id' => $this->whenHas('continuation_id'),
'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)),
'quoted_commit_id' => $this->quoted_commit_id,
'quoted_commit' => $this->whenLoaded('quotedCommit', fn (Commit $quoted): self => self::make($quoted)),
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Exceptions\ApiException;
use App\Http\Controllers\Api\CallController;
use App\Http\Controllers\Api\CommitContinuationController;
use App\Http\Controllers\Api\CommitController as ApiCommitController;
use App\Http\Controllers\Api\CommitPastelController;
use App\Http\Controllers\Api\CommitPinController;
Expand Down Expand Up @@ -260,6 +261,8 @@
Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index'])
->withTrashed()
->name('api-commit-reply.index');
Route::get('commits/{commit}/continuation', [CommitContinuationController::class, 'index'])
->name('api-commit-continuation.index');

// Commit Report: a commit that doesn't exist or was deleted gets the same answer as one the user may not see...
Route::post('commits/{commit}/reports', [CommitReportController::class, 'store'])
Expand Down
174 changes: 174 additions & 0 deletions tests/Feature/Controllers/Api/CommitContinuationControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

declare(strict_types=1);

use App\Jobs\DetectCommitLanguage;
use App\Models\Commit;
use App\Models\User;
use App\Queries\CommitContinuationQuery;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;

beforeEach(function (): void {
$this->user = User::factory()->create();
$this->keyPair = apiKeyPairFor($this->user);
});

it('lists the next commits of the author thread as the API shows commits', function (): void {
$author = User::factory()->create();
$commit = Commit::factory()->for($author)->create(['created_at' => now()->subMinutes(5)]);
$first = Commit::factory()->for($author)->for($commit, 'parent')->create(['created_at' => now()->subMinutes(4)]);
$second = Commit::factory()->for($author)->for($first, 'parent')->create(['created_at' => now()->subMinutes(3)]);
Commit::factory()->for($author)->for($commit, 'parent')->create(['created_at' => now()->subMinutes(2)]);
Commit::factory()->for($first, 'parent')->create(['created_at' => now()->subMinute()]);

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commit))
->assertOk()
->assertJsonPath('data.*.id', [$first->id, $second->id])
->assertJsonPath('data.0.author.handle', $author->handle)
->assertJsonPath('data.0.continuation_id', $second->id)
->assertJsonPath('data.0.replies_count', 1)
->assertJsonPath('data.1.continuation_id', null)
->assertJsonPath('meta.position', 1)
->assertJsonPath('meta.length', 2)
->assertJsonPath('meta.is_capped', false)
->assertJsonPath('meta.next_cursor', null)
->assertJsonPath('links.next', null);
});

it('lists the thread 10 commits at a time with a cursor to the next page', function (): void {
$author = User::factory()->create();
$commits = [Commit::factory()->for($author)->create()];

foreach (range(1, CommitContinuationQuery::PER_PAGE + 2) as $ignored) {
$commits[] = Commit::factory()->for($author)->for(end($commits), 'parent')->create();
}

$first = signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commits[0]));

$first->assertOk()
->assertJsonPath('data.*.id', array_map(fn (Commit $commit): string => $commit->id, array_slice($commits, 1, 10)))
->assertJsonPath('data.9.continuation_id', $commits[11]->id)
->assertJsonPath('meta.position', 1)
->assertJsonPath('meta.length', 12);

$second = signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$commits[0], 'cursor' => $first->json('meta.next_cursor')]));

$second->assertOk()
->assertJsonPath('data.*.id', [$commits[11]->id, $commits[12]->id])
->assertJsonPath('meta.position', 11)
->assertJsonPath('meta.length', 2)
->assertJsonPath('meta.next_cursor', null);

signedApiRequest($this->keyPair, 'GET', (string) $first->json('links.next'))
->assertOk()
->assertJsonPath('data.*.id', [$commits[11]->id, $commits[12]->id]);
});

it('does not query each commit of the thread, its author or its stars', function (): void {
Queue::fake([DetectCommitLanguage::class]);
$author = User::factory()->create();
$commits = [Commit::factory()->for($author)->create()];

$queries = function () use ($commits): int {
DB::flushQueryLog();
DB::enableQueryLog();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commits[0]))->assertOk();

return count(DB::getQueryLog());
};

$create = function (int $count) use (&$commits, $author): void {
foreach (range(1, $count) as $ignored) {
$commits[] = $commit = Commit::factory()->for($author)->for(end($commits), 'parent')->create();
$commit->stars()->attach([$this->user->id, User::factory()->create()->id]);
}
};

$create(2);

// The first signed request also syncs the user's keys with GitHub, and the first cards load the features...
$queries();

$few = $queries();

$create(8);

expect($queries())->toBe($few);
});

it('lists the first page for a cursor that is not one', function (mixed $cursor): void {
$commit = Commit::factory()->create();
$next = Commit::factory()->for($commit->user)->for($commit, 'parent')->create();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$commit, 'cursor' => $cursor]))
->assertOk()
->assertJsonPath('data.*.id', [$next->id]);
})->with([
'malformed' => ['not-a-cursor'],
'array' => [['not-a-cursor']],
]);

it('returns not found for a cursor on something that is not a commit', function (): void {
$commit = Commit::factory()->create();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$commit, 'cursor' => new Cursor(['thread_id' => $commit->id, 'id' => 'not-a-uuid'])->encode()]))
->assertNotFound();
});

it('returns not found for a cursor from the thread of another commit', function (): void {
$commit = Commit::factory()->create();
Commit::factory()->for($commit->user)->for($commit, 'parent')->create();
$other = [Commit::factory()->create()];

foreach (range(1, CommitContinuationQuery::PER_PAGE + 1) as $ignored) {
$other[] = Commit::factory()->for($other[0]->user)->for(end($other), 'parent')->create();
}

$cursor = signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $other[0]))->json('meta.next_cursor');

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$other[0], 'cursor' => $cursor]))->assertOk();
signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$commit, 'cursor' => $cursor]))->assertNotFound();
});

it('stops the thread at the first commit the user may not see', function (): void {
$author = User::factory()->create();
$commit = Commit::factory()->for($author)->create();
$visible = Commit::factory()->for($author)->for($commit, 'parent')->create();
$pending = Commit::factory()->for($author)->for($visible, 'parent')->pending()->create();
Commit::factory()->for($author)->for($pending, 'parent')->create();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commit))
->assertOk()
->assertJsonPath('data.*.id', [$visible->id])
->assertJsonPath('data.0.continuation_id', null)
->assertJsonPath('meta.length', 1);
});

it('returns not found for the thread of a commit the user may not see', function (): void {
$commit = Commit::factory()->pending()->create();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commit))->assertNotFound();
});

it('returns not found for the thread of a commit across a block', function (): void {
$commit = Commit::factory()->create();
Commit::factory()->for($commit->user)->for($commit, 'parent')->create();
$this->user->blocking()->attach($commit->user);

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', $commit))->assertNotFound();
});

it('returns not found for a cursor on a commit the user may not see', function (): void {
$commit = Commit::factory()->create();
$hidden = Commit::factory()->pending()->create();

signedApiRequest($this->keyPair, 'GET', route('api-commit-continuation.index', [$commit, 'cursor' => new Cursor(['thread_id' => $commit->id, 'id' => $hidden->id])->encode()]))
->assertNotFound();
});

it('rejects requests that are not signed', function (): void {
$this->getJson(route('api-commit-continuation.index', Commit::factory()->create()))->assertUnauthorized();
});
Loading
Loading