From ec3a5904bc0fc071eb02063f0fd2ee98e02fec59 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Fri, 30 Oct 2026 12:41:59 +0100 Subject: [PATCH 1/9] feat: add commit source attribution --- app/Actions/CreateCommit.php | 6 ++-- app/Actions/CreateExport.php | 3 +- app/Actions/CreateScheduledCommit.php | 4 ++- .../UpdateScheduledCommitSubmittedAt.php | 2 +- app/Enums/CommitSource.php | 17 ++++++++++ app/Http/Controllers/Api/CommitController.php | 5 +-- .../Api/ScheduledCommitController.php | 5 +-- app/Http/Controllers/Cli/CommitController.php | 3 +- app/Http/Controllers/CommitController.php | 3 +- .../Controllers/CommitReplyController.php | 3 +- .../Controllers/ScheduledCommitController.php | 3 +- app/Http/Resources/CommitResource.php | 1 + .../Resources/ScheduledCommitResource.php | 1 + app/Models/Commit.php | 3 ++ app/Models/ScheduledCommit.php | 5 ++- app/Queries/CommitBuilder.php | 2 +- app/Services/CommitSourceResolver.php | 29 ++++++++++++++++ ...o_commits_and_scheduled_commits_tables.php | 21 ++++++++++++ .../Controllers/Api/CommitControllerTest.php | 29 ++++++++++++++++ .../Api/ScheduledCommitControllerTest.php | 21 ++++++++++++ .../Controllers/Cli/CommitControllerTest.php | 4 ++- .../Controllers/CommitControllerTest.php | 4 ++- .../Controllers/ExportControllerTest.php | 4 ++- .../Services/CommitSourceResolverTest.php | 33 +++++++++++++++++++ 24 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 app/Enums/CommitSource.php create mode 100644 app/Services/CommitSourceResolver.php create mode 100644 database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php create mode 100644 tests/Unit/Services/CommitSourceResolverTest.php diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 9bc21f6ba..4789bb22f 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -6,6 +6,7 @@ use App\Dto\NewPoll; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Jobs\CheckUserLevel; use App\Jobs\EmbedCommit; @@ -55,7 +56,7 @@ public function __construct( * @param array $imageDescriptions * @param array $replyRecipients */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null, ?CommitSource $source = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -63,7 +64,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization, $source): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -73,6 +74,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'user_id' => $user->id, 'author_id' => $organization->id ?? $user->id, 'body' => $this->emojiShortcodes->replace($body), + 'source' => $source, 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, 'root_id' => $parent?->threadRootId(), diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 289e5abb1..fa97c1a8b 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -189,7 +189,7 @@ private function commits(User $user): Generator { $commits = $user->commits() ->withTrashed() - ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) + ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'source', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -204,6 +204,7 @@ private function commits(User $user): Generator 'parent_id' => $commit->parent_id, 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, + 'source' => $commit->source?->value, 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, 'pasteis_count' => $commit->pasteis_count, diff --git a/app/Actions/CreateScheduledCommit.php b/app/Actions/CreateScheduledCommit.php index 6d00ed8d5..99e922555 100644 --- a/app/Actions/CreateScheduledCommit.php +++ b/app/Actions/CreateScheduledCommit.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Dto\NewPoll; +use App\Enums\CommitSource; use App\Models\Organization; use App\Models\ScheduledCommit; use App\Models\User; @@ -27,7 +28,7 @@ public function __construct(private CreateImageFiles $createImageFiles, private * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null): ScheduledCommit + public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null, ?CommitSource $source = null): ScheduledCommit { $body = mb_trim($body); @@ -35,6 +36,7 @@ public function handle(User $user, string $body, CarbonImmutable $scheduledAt, a 'user_id' => $user->id, 'author_id' => $organization?->id, 'body' => $body, + 'source' => $source, 'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null, 'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions), 'link_preview_url' => match ($linkPreview) { diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php index d3f0612e9..2fec18f02 100644 --- a/app/Actions/UpdateScheduledCommitSubmittedAt.php +++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php @@ -57,7 +57,7 @@ public function handle(string $id, bool $early = false): bool $scheduled = ScheduledCommit::query()->with(['user', 'author'])->findOrFail($id); assert(is_string($scheduled->body)); $poll = $scheduled->poll === null ? null : new NewPoll($scheduled->poll['options'], PollDuration::from($scheduled->poll['duration'])); - $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization); + $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization, source: $scheduled->source); $commit->images()->createMany($scheduled->images ?? []); $scheduled->forceFill(['commit_id' => $commit->id, 'body' => null, 'poll' => null, 'images' => null, 'link_preview_url' => null])->save(); diff --git a/app/Enums/CommitSource.php b/app/Enums/CommitSource.php new file mode 100644 index 000000000..a70b3287a --- /dev/null +++ b/app/Enums/CommitSource.php @@ -0,0 +1,17 @@ +handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization(), source: $sources->from($request)); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Controllers/Api/ScheduledCommitController.php b/app/Http/Controllers/Api/ScheduledCommitController.php index ec9b1fe5d..125b3ecae 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -12,6 +12,7 @@ use App\Models\ScheduledCommit; use App\Models\User; use App\Queries\ScheduledCommitsQuery; +use App\Services\CommitSourceResolver; use Carbon\CarbonImmutable; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; @@ -31,9 +32,9 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule /** * Only the user sees a scheduled commit until it is submitted. */ - public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse + public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action, CommitSourceResolver $sources): JsonResponse { - $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), $sources->from($request)); return ScheduledCommitResource::make($scheduled) ->response() diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index a2e10a1d1..2814a86b3 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Cli; use App\Actions\CreateCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCliCommitRequest; use App\Models\User; @@ -15,7 +16,7 @@ { public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: CommitSource::Cli); $url = route('commit.show', [$user, $commit]); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index ecfe35a1e..173319f75 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -7,6 +7,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; use App\Dto\ThreadContinuation; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitRequest; use App\Http\Requests\DeleteCommitRequest; @@ -68,7 +69,7 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C $before = $achievements->unlockedBadgeNames($user); $organization = $request->organization(); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization, source: CommitSource::Web); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index 9a5b63926..0067edd1f 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -6,6 +6,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitReplyRequest; use App\Http\Requests\DeleteCommitReplyRequest; @@ -28,7 +29,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { $organization = $request->organization(); - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization, source: CommitSource::Web); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 84c50a53d..cfb1a58a4 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -6,6 +6,7 @@ use App\Actions\CreateScheduledCommit; use App\Actions\DeleteScheduledCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateScheduledCommitRequest; use App\Http\Requests\DeleteScheduledCommitRequest; use App\Http\Resources\ScheduledCommitResource; @@ -32,7 +33,7 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { - $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), CommitSource::Web); Inertia::flash('scheduled', true); Inertia::flash('toast', ['type' => 'success', 'message' => 'Commit scheduled. Only you can see it until it is submitted.']); diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 31dc2a058..ca1e2a02c 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -56,6 +56,7 @@ public function toArray(Request $request): array 'id' => $this->id, 'short_hash' => mb_substr(str_replace('-', '', $this->id), -7), 'body' => $this->body, + 'source' => $this->source?->value, 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->author, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), diff --git a/app/Http/Resources/ScheduledCommitResource.php b/app/Http/Resources/ScheduledCommitResource.php index c8f1346be..5a08c6861 100644 --- a/app/Http/Resources/ScheduledCommitResource.php +++ b/app/Http/Resources/ScheduledCommitResource.php @@ -26,6 +26,7 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'author' => $this->author === null ? null : ProfileResource::make($this->author)->resolve($request), + 'source' => $this->source?->value, ...($this->submitted_at === null && $this->body !== null ? ['body' => $this->body, ...resolve(CommitBodies::class)->segments($this->body)] : ['body' => null, 'body_segments' => [], 'preview_segments' => null]), diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de..7921addc7 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Queries\CommitBuilder; use App\Services\CommitBodies; @@ -31,6 +32,7 @@ * @property-read string|null $root_id * @property-read float $hot_score * @property-read string $body + * @property-read CommitSource|null $source * @property-read array|null $mentions * @property-read array|null $organization_mentions * @property-read CommitStatus $status @@ -124,6 +126,7 @@ public function casts(): array 'hot_score' => 'float', 'pasteis_count' => 'integer', 'body' => 'string', + 'source' => CommitSource::class, 'mentions' => 'array', 'organization_mentions' => 'array', 'status' => CommitStatus::class, diff --git a/app/Models/ScheduledCommit.php b/app/Models/ScheduledCommit.php index 5bd7170ce..d2e8db7e5 100644 --- a/app/Models/ScheduledCommit.php +++ b/app/Models/ScheduledCommit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use Carbon\CarbonInterface; use Database\Factories\ScheduledCommitFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -18,6 +19,7 @@ * @property-read string|null $author_id * @property-read string|null $commit_id * @property-read string|null $body + * @property-read CommitSource|null $source * @property-read array{options: list, duration: int}|null $poll * @property-read list|null $images * @property-read string|null $link_preview_url @@ -28,7 +30,7 @@ * @property-read Profile|null $author * @property-read Commit|null $commit */ -#[Fillable(['user_id', 'author_id', 'body', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] +#[Fillable(['user_id', 'author_id', 'body', 'source', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] final class ScheduledCommit extends Model { /** @use HasFactory */ @@ -42,6 +44,7 @@ public function casts(): array return [ 'poll' => 'array', 'images' => 'array', + 'source' => CommitSource::class, 'scheduled_at' => 'immutable_datetime', 'submitted_at' => 'immutable_datetime', 'canceled_at' => 'immutable_datetime', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 9ce30e753..e718de7c9 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -29,7 +29,7 @@ final class CommitBuilder extends Builder * Every column but the embedding, in the table's order. */ private const array COLUMNS_BUT_EMBEDDING = [ - 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', + 'id', 'user_id', 'body', 'source', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', 'author_id', 'headlines_classified_at', ]; diff --git a/app/Services/CommitSourceResolver.php b/app/Services/CommitSourceResolver.php new file mode 100644 index 000000000..86b5c2520 --- /dev/null +++ b/app/Services/CommitSourceResolver.php @@ -0,0 +1,29 @@ +header('X-Devnull-Client', '')); + + if ($source instanceof CommitSource) { + return $source; + } + + return match (true) { + preg_match('/^DevNull-Mac\/[^\s]+$/', $request->userAgent() ?? '') === 1 => CommitSource::Macos, + preg_match('/^devnull-tui\/[^\s]+$/', $request->userAgent() ?? '') === 1 => CommitSource::Tui, + default => null, + }; + } +} diff --git a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php new file mode 100644 index 000000000..3cf49a967 --- /dev/null +++ b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php @@ -0,0 +1,21 @@ +string('source', 16)->nullable()->after('body'); + }); + + Schema::table('scheduled_commits', function (Blueprint $table): void { + $table->string('source', 16)->nullable()->after('body'); + }); + } +}; diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 79bb90d31..21f4d293b 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\PollDuration; use App\Http\Requests\CreateApiCommitRequest; @@ -65,6 +66,7 @@ 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', 'short_hash' => 'e3f4a5b', 'body' => 'Shipped the public API', + 'source' => null, 'parent_id' => null, 'html_url' => route('commit.show', [$user, $commit]), 'created_at' => now()->toIso8601ZuluString(), @@ -295,6 +297,7 @@ 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', 'short_hash' => 'e3f4a5b', 'body' => 'Ask @nunomaduro about `@Override`', + 'source' => null, 'parent_id' => $root->id, 'html_url' => route('commit.show', [$user, $commit]), 'created_at' => now()->toIso8601ZuluString(), @@ -324,6 +327,7 @@ 'id' => $root->id, 'short_hash' => mb_substr(str_replace('-', '', $root->id), -7), 'body' => 'Who broke prod?', + 'source' => null, 'parent_id' => null, 'html_url' => route('commit.show', [$root->user, $root]), 'created_at' => now()->subMinute()->toIso8601ZuluString(), @@ -577,6 +581,31 @@ expect($commit->status)->toBe(CommitStatus::Pending); }); +it('records a signed clients source from its established product name', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $body = json_encode(['body' => 'TUI post'], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api-commit.store'), server: [ + ...signApiRequest($keyPair, 'POST', route('api-commit.store'), $body), + 'HTTP_USER_AGENT' => 'devnull-tui/1.2.3', + ], content: $body); + + $response->assertCreated()->assertJsonPath('data.source', 'tui'); + + expect($user->commits()->sole()->source)->toBe(CommitSource::Tui); +}); + +it('leaves a signed commit without a recognized client source unlabelled', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'Unknown client'])->assertCreated() + ->assertJsonPath('data.source', null); + + expect($user->commits()->sole()->source)->toBeNull(); +}); + it('publishes the commits of admins right away', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); diff --git a/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php index 0ab8afb0a..f7228c0a0 100644 --- a/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\DeleteScheduledCommit; use App\Actions\UpdateScheduledCommitSubmittedAt; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\PollDuration; use App\Jobs\UnfurlLink; @@ -66,6 +67,26 @@ ->and(Commit::query()->count())->toBe(0); }); +it('carries the scheduled commits client source into publication', function (): void { + $body = json_encode([ + 'body' => 'Shipped from macOS.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ], JSON_THROW_ON_ERROR); + + $this->call('POST', route('api-scheduled-commit.store'), server: [ + ...signApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), $body), + 'HTTP_USER_AGENT' => 'DevNull-Mac/1.0', + ], content: $body)->assertCreated()->assertJsonPath('data.source', 'macos'); + + $scheduled = ScheduledCommit::query()->sole(); + expect($scheduled->source)->toBe(CommitSource::Macos); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect(Commit::query()->sole()->source)->toBe(CommitSource::Macos); +}); + it('schedules a poll that opens with its commit at the due time', function (): void { signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ 'body' => 'Tabs or spaces?', diff --git a/tests/Feature/Controllers/Cli/CommitControllerTest.php b/tests/Feature/Controllers/Cli/CommitControllerTest.php index 85b4d0baf..5ee31acbb 100644 --- a/tests/Feature/Controllers/Cli/CommitControllerTest.php +++ b/tests/Feature/Controllers/Cli/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\ModerationReason; use App\Http\Middleware\AuthenticateSshSignature; @@ -82,7 +83,8 @@ function imageHashes(array $images): array ->assertCookieMissing(config('session.cookie')); expect($commit->body)->toBe('It was DNS.') - ->and($commit->status)->toBe(CommitStatus::Pending); + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($commit->source)->toBe(CommitSource::Cli); }); it('collapses runs of blank lines in a signed commit', function (): void { diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 8edc654c7..b35a4037b 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\CreateQuoteNotifications; use App\Actions\DeleteUser; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; @@ -33,7 +34,8 @@ $response->assertRedirectToRoute('log.show', Log::Following); - expect($user->commits()->sole()->body)->toBe('Hello, devnull!'); + expect($user->commits()->sole()->body)->toBe('Hello, devnull!') + ->and($user->commits()->sole()->source)->toBe(CommitSource::Web); }); it('flashes the created commit', function (): void { diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 60160eaca..9cee60241 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -7,6 +7,7 @@ use App\Actions\CreateExport; use App\Actions\DeleteUser; use App\Contracts\Services\TranslationProvider; +use App\Enums\CommitSource; use App\Enums\ModerationReason; use App\Enums\NotificationType; use App\Enums\OrganizationRole; @@ -215,7 +216,7 @@ $user = User::factory()->create(); $other = User::factory()->create(); $parent = Commit::factory()->for($other)->rejected()->create(['body' => 'Another developer private parent body']); - $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support ๐Ÿš€\nNext: docs.", 'pasteis_count' => 3]); + $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support ๐Ÿš€\nNext: docs.", 'source' => CommitSource::Tui, 'pasteis_count' => 3]); $approved->headlines()->create(['name' => 'Unicode support ships']); $pending = Commit::factory()->for($user)->pending()->create(); $rejected = Commit::factory()->for($user)->rejected()->create(); @@ -243,6 +244,7 @@ 'parent_id' => null, 'quoted_commit_id' => null, 'body' => $approved->body, + 'source' => 'tui', 'cached_translations' => [ 'source_language' => 'en', 'translations' => ['nl' => 'Unicode-ondersteuning geleverd ๐Ÿš€'], diff --git a/tests/Unit/Services/CommitSourceResolverTest.php b/tests/Unit/Services/CommitSourceResolverTest.php new file mode 100644 index 000000000..216cd7932 --- /dev/null +++ b/tests/Unit/Services/CommitSourceResolverTest.php @@ -0,0 +1,33 @@ + $expected->value]); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBe($expected); +})->with(CommitSource::cases()); + +it('recognizes established client product names', function (string $agent, CommitSource $expected): void { + $request = Request::create('/', server: ['HTTP_USER_AGENT' => $agent]); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBe($expected); +})->with([ + ['DevNull-Mac/1.0', CommitSource::Macos], + ['devnull-tui/1.2.3', CommitSource::Tui], +]); + +it('leaves unknown client identifiers unlabelled', function (array $server): void { + $request = Request::create('/', server: $server); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBeNull(); +})->with([ + 'no identifier' => [[]], + 'unknown header' => [['HTTP_X_DEVNULL_CLIENT' => 'browser']], + 'generic browser' => [['HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)']], + 'near mac product' => [['HTTP_USER_AGENT' => 'DevNull-Mac']], +]); From 59dd05b832e57e4d7fd92bb10269184d250577ea Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Fri, 30 Oct 2026 13:45:45 +0100 Subject: [PATCH 2/9] feat: show commit source badges --- DESIGN.md | 2 + resources/js/components/commit-card.tsx | 5 +- .../js/components/commit-source-badge.tsx | 37 ++++++++++ resources/js/components/commit-thread.tsx | 4 +- resources/js/components/quoted-commit.tsx | 4 +- resources/js/components/search-dialog.tsx | 2 + .../js/pages/scheduled-commits/index.tsx | 22 ++++-- resources/js/types/commit.ts | 2 + tests/Unit/CommitSourceBadge.test.tsx | 67 +++++++++++++++++++ tests/Unit/QuotedCommit.test.tsx | 9 +++ 10 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 resources/js/components/commit-source-badge.tsx create mode 100644 tests/Unit/CommitSourceBadge.test.tsx diff --git a/DESIGN.md b/DESIGN.md index abe4bab8b..f9450fba3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -280,6 +280,8 @@ Quiet, rectangular, decisive. ### Commit Row (signature) The graph rail, then avatar (40px circle), then a header line: author name (600), mono handle, a middle dot, mono relative time, and the 7-character short hash pushed right (from sm). The body follows at 15px, pre-wrapped. Under the body, a quiet star: a 16px outline icon in Muted Ink, filled in Commit Blue once starred, with the count in mono 13px beside it when the count is at least 1. It is hidden on commits the viewer cannot star. The toggle updates immediately and rolls back if the request fails, without reloading the log. Your own rows reveal a trash icon on hover/focus (always visible on touch). Copy link and Report sit at the right of the action row and reveal the same way, a reply's only while that reply is hovered; on phones they live in a "โ€ฆ" menu. Deleting is inline: a soft red strip asks "Delete this commit? There's no undo." with Cancel and Delete; confirming swaps the body for a mono `rm {hash} โ†’ /dev/null` status, then the row collapses (height and opacity, 300ms after a 400ms hold). +**Source badge:** When present, the creating client appears immediately after the relative time as a quiet, non-interactive 4px-cornered Muted Ink label in 12px Geist Mono. The frontend renders the backend-provided source verbatim; current values include `web`, `cli`, `tui`, `ios`, `android`, `macos`, `windows`, and `linux`. Hovering it opens the standard metadata tooltip with the literal copy `Committed via {source}`; the label remains available as its accessible name. Missing attribution shows no label. Thread position and moderation labels follow it. Compact replies, quotes, search results, and scheduled commits use the same label at 11px; headers wrap metadata below the author on narrow widths rather than truncate a known source. + ### Graph Rail (signature) A 14px circle (16px on the landing page) centered on a 2px vertical line. The segment above a node takes the previous author's lane; the segment below takes this author's. The first commit hangs from a Rail Gray segment beneath the staged composer. The end of history is a hollow **root** node with no tail and a muted line ("That's the root commit. There's nothing before it."). A commit you made in the last 15 seconds animates in with `node-in` (scale 0 to 1.25 to 1 with a lane-colored pulse, 700ms, expo-out); landing-page segments draw downward with `rail-draw`, staggered 140ms. Both respect reduced motion. diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 000353a52..23e4e3e75 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -10,6 +10,7 @@ import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; import { CommitQuoteButton } from '@/components/commit-quote-button'; import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { @@ -347,7 +348,7 @@ export function CommitCard({ })} >
-
+
+ + diff --git a/resources/js/components/commit-source-badge.tsx b/resources/js/components/commit-source-badge.tsx new file mode 100644 index 000000000..cd011dff5 --- /dev/null +++ b/resources/js/components/commit-source-badge.tsx @@ -0,0 +1,37 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +/** A quiet, non-interactive record of the client that created a commit. */ +export function CommitSourceBadge({ + source, + size = 'default', +}: { + source?: string | null; + size?: 'default' | 'small'; +}) { + if (source === null || source === undefined) { + return null; + } + + return ( + + + + {source} + + + Committed via {source} + + ); +} diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 18832c276..cde163bba 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -34,6 +34,7 @@ import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { useCommitTranslation } from '@/components/commit-translation'; @@ -1198,7 +1199,7 @@ function CommitReply({ 'opacity-50': wasDeleted, })} > -
+
+ {isOwn && ( diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index cba9565d6..ecf8ecf36 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -5,6 +5,7 @@ import { AuthorAvatar } from '@/components/author-avatar'; import { CommitBodyExcerpt } from '@/components/commit-body'; import { CommitImages } from '@/components/commit-images'; import { CommitPoll } from '@/components/commit-poll'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { LinkPreview } from '@/components/link-preview'; import { useNow } from '@/hooks/use-now'; @@ -46,7 +47,7 @@ export function QuotedCommit({ const content = ( <> - + {label} @@ -65,6 +66,7 @@ export function QuotedCommit({ > {formatRelativeTime(commit.created_at, now)} + {formatRelativeTime(commit.created_at, now)} + - +
+ + +
{scheduled.author && (

+ +import assert from 'node:assert/strict'; +import { Children, isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { CommitSourceBadge } from '../../resources/js/components/commit-source-badge'; +import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; + +const labels = { + web: 'web', + cli: 'cli', + tui: 'tui', + ios: 'ios', + android: 'android', + macos: 'macos', + windows: 'windows', + linux: 'linux', +}; + +for (const [source, label] of Object.entries(labels)) { + test(`labels ${source} commits as ${label}`, () => { + const badge = renderToStaticMarkup( + + + , + ); + + assert.match(badge, new RegExp(`>${label}$`)); + assert.match(badge, new RegExp(`aria-label="Committed via ${label}"`)); + }); +} + +test('shows the source in its lazy hover tooltip', () => { + const badge = CommitSourceBadge({ source: 'web' }); + const tooltip = Children.toArray(badge?.props.children).at(1); + + assert.ok(isValidElement<{ children: ReactNode }>(tooltip)); + assert.equal( + renderToStaticMarkup(<>{tooltip.props.children}), + 'Committed via web', + ); +}); + +test('renders a newly introduced backend source without a frontend change', () => { + const badge = renderToStaticMarkup( + + + , + ); + + assert.match(badge, />headless<\/span>$/); + assert.match(badge, /aria-label="Committed via headless"/); +}); + +for (const source of [null, undefined]) { + test(`omits a ${String(source)} source`, () => { + assert.equal( + renderToStaticMarkup( + + + , + ), + '', + ); + }); +} diff --git a/tests/Unit/QuotedCommit.test.tsx b/tests/Unit/QuotedCommit.test.tsx index a790caf25..fe8b9f7b9 100644 --- a/tests/Unit/QuotedCommit.test.tsx +++ b/tests/Unit/QuotedCommit.test.tsx @@ -25,6 +25,7 @@ const commit = { ], preview_segments: null, status: 'approved', + source: 'tui', created_at: '2026-10-16T11:51:00.000000Z', author: { id: 'author', name: 'Matthieu Napoli', handle: 'mnapoli' }, } as QuotedCommitData; @@ -85,6 +86,14 @@ test('keeps the quote a single link', async () => { assert.doesNotMatch(body, / { + const source = await render(false); + const unknown = await render(false, { ...commit, source: null }); + + assert.match(source, /data-test="commit-source"[^>]*>tui { const image = { id: 'image', From 95e16403daec42a2f10013dc1605293e51990e47 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Fri, 30 Oct 2026 21:13:10 +0100 Subject: [PATCH 3/9] docs: document commit source workflows --- app/Http/Controllers/Cli/CommitController.php | 1 + app/Http/Controllers/CommitController.php | 1 + app/Http/Controllers/ScheduledCommitController.php | 1 + ...120000_add_source_to_commits_and_scheduled_commits_tables.php | 1 + 4 files changed, 4 insertions(+) diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index 2814a86b3..729b4575c 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -14,6 +14,7 @@ final readonly class CommitController { + /** Creates a CLI commit and returns its URL, noting when it is awaiting moderation. */ public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: CommitSource::Cli); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 173319f75..5655382b8 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -64,6 +64,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs ])->withViewData(['preview' => $preview]); } + /** Creates a web commit and flashes any achievements it unlocks for its author. */ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, UserAchievementsQuery $achievements): RedirectResponse { $before = $achievements->unlockedBadgeNames($user); diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index cfb1a58a4..c9721f1f1 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -31,6 +31,7 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm ]); } + /** Schedules a web commit and marks the session so its queue refreshes. */ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), CommitSource::Web); diff --git a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php index 3cf49a967..b24a98826 100644 --- a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php +++ b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php @@ -8,6 +8,7 @@ return new class extends Migration { + /** Adds nullable source metadata without requiring attribution for existing commits. */ public function up(): void { Schema::table('commits', function (Blueprint $table): void { From 01f70f80c43d3867f19ba80c51f059f6d200faf7 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Sun, 1 Nov 2026 13:57:56 +0100 Subject: [PATCH 4/9] docs: describe commit source serialization --- app/Actions/CreateExport.php | 2 ++ app/Http/Resources/CommitResource.php | 2 ++ app/Http/Resources/ScheduledCommitResource.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index fa97c1a8b..94f9ed695 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -183,6 +183,8 @@ public function handle(User $user): array } /** + * Streams every commit the user owns, including deleted ones, with its recorded source and cached translations. + * * @return Generator> */ private function commits(User $user): Generator diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index ca1e2a02c..d0089d50a 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -37,6 +37,8 @@ final class CommitResource extends JsonResource { /** + * Serializes public commits, signed-only fields, and deleted thread tombstones without exposing their body. + * * @return array */ public function toArray(Request $request): array diff --git a/app/Http/Resources/ScheduledCommitResource.php b/app/Http/Resources/ScheduledCommitResource.php index 5a08c6861..94fad3aa2 100644 --- a/app/Http/Resources/ScheduledCommitResource.php +++ b/app/Http/Resources/ScheduledCommitResource.php @@ -19,6 +19,8 @@ final class ScheduledCommitResource extends JsonResource { /** + * Serializes an owner's scheduled commit, withholding its body after submission or deletion. + * * @return array */ public function toArray(Request $request): array From 648610f5329dfcc5c87906ab8796a6fa8cd22985 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Sun, 1 Nov 2026 14:21:58 +0100 Subject: [PATCH 5/9] docs: format commit source docblocks --- app/Http/Controllers/Cli/CommitController.php | 4 +++- app/Http/Controllers/CommitController.php | 4 +++- app/Http/Controllers/ScheduledCommitController.php | 4 +++- ...000_add_source_to_commits_and_scheduled_commits_tables.php | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index 729b4575c..a4c14c83e 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -14,7 +14,9 @@ final readonly class CommitController { - /** Creates a CLI commit and returns its URL, noting when it is awaiting moderation. */ + /** + * Creates a CLI commit and returns its URL, noting when it is awaiting moderation. + */ public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: CommitSource::Cli); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 5655382b8..bafb88a2b 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -64,7 +64,9 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs ])->withViewData(['preview' => $preview]); } - /** Creates a web commit and flashes any achievements it unlocks for its author. */ + /** + * Creates a web commit and flashes any achievements it unlocks for its author. + */ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, UserAchievementsQuery $achievements): RedirectResponse { $before = $achievements->unlockedBadgeNames($user); diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index c9721f1f1..71df7339c 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -31,7 +31,9 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm ]); } - /** Schedules a web commit and marks the session so its queue refreshes. */ + /** + * Schedules a web commit and marks the session so its queue refreshes. + */ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), CommitSource::Web); diff --git a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php index b24a98826..eda75c88d 100644 --- a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php +++ b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php @@ -8,7 +8,9 @@ return new class extends Migration { - /** Adds nullable source metadata without requiring attribution for existing commits. */ + /** + * Adds nullable source metadata without requiring attribution for existing commits. + */ public function up(): void { Schema::table('commits', function (Blueprint $table): void { From ba9fdfa9107bc1d5ad7e9b2cac7a2216ae04a18e Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Sun, 1 Nov 2026 15:02:14 +0100 Subject: [PATCH 6/9] fix: announce commit source badges --- resources/js/components/commit-source-badge.tsx | 2 +- tests/Unit/CommitSourceBadge.test.tsx | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/resources/js/components/commit-source-badge.tsx b/resources/js/components/commit-source-badge.tsx index cd011dff5..cabc71e72 100644 --- a/resources/js/components/commit-source-badge.tsx +++ b/resources/js/components/commit-source-badge.tsx @@ -25,9 +25,9 @@ export function CommitSourceBadge({ 'shrink-0 self-center rounded-sm bg-muted px-1.5 font-mono text-muted-foreground', size === 'default' ? 'py-0.5 text-xs' : 'text-[11px]', )} - aria-label={`Committed via ${source}`} data-test="commit-source" > + Committed via {source} diff --git a/tests/Unit/CommitSourceBadge.test.tsx b/tests/Unit/CommitSourceBadge.test.tsx index 60ee969a9..aaeeafc0a 100644 --- a/tests/Unit/CommitSourceBadge.test.tsx +++ b/tests/Unit/CommitSourceBadge.test.tsx @@ -26,8 +26,13 @@ for (const [source, label] of Object.entries(labels)) { , ); - assert.match(badge, new RegExp(`>${label}$`)); - assert.match(badge, new RegExp(`aria-label="Committed via ${label}"`)); + assert.match( + badge, + new RegExp( + `Committed via ${label}$`, + ), + ); + assert.doesNotMatch(badge, /aria-label=/); }); } @@ -49,8 +54,11 @@ test('renders a newly introduced backend source without a frontend change', () = , ); - assert.match(badge, />headless<\/span>$/); - assert.match(badge, /aria-label="Committed via headless"/); + assert.match( + badge, + /Committed via <\/span>headless<\/span>$/, + ); + assert.doesNotMatch(badge, /aria-label=/); }); for (const source of [null, undefined]) { From 43577469667f7557746d87bcd6cff50ca5c6ede4 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Mon, 2 Nov 2026 09:56:51 +0100 Subject: [PATCH 7/9] test: cover accessible commit source badge --- tests/Unit/QuotedCommit.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Unit/QuotedCommit.test.tsx b/tests/Unit/QuotedCommit.test.tsx index fe8b9f7b9..b18634327 100644 --- a/tests/Unit/QuotedCommit.test.tsx +++ b/tests/Unit/QuotedCommit.test.tsx @@ -90,7 +90,10 @@ test('shows its own known source and omits an unknown one', async () => { const source = await render(false); const unknown = await render(false, { ...commit, source: null }); - assert.match(source, /data-test="commit-source"[^>]*>tui]*>Committed via <\/span>tui Date: Mon, 2 Nov 2026 10:25:49 +0100 Subject: [PATCH 8/9] fix: retain source in organization exports --- app/Actions/CreateExport.php | 1 + resources/js/components/search-dialog.tsx | 2 +- tests/Feature/Controllers/ExportControllerTest.php | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 94f9ed695..ea4cb2b52 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -241,6 +241,7 @@ private function organizationCommits(User $user): Generator 'parent_id' => $commit->parent_id, 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, + 'source' => $commit->source?->value, 'status' => $commit->status->value, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 639cf5a7e..d305f5bdd 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -889,7 +889,7 @@ function CommitResult({ commit }: { commit: Commit }) { className="size-9 shrink-0 self-start" /> - + ยท diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 9cee60241..773cee732 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -266,6 +266,19 @@ ->and($response->streamedContent())->not->toContain($parent->body, $otherReply->body); }); +it('exports the source of commits posted for organizations', function (): void { + $user = User::factory()->create(); + $organization = Organization::factory()->create(['handle' => 'laravel']); + Commit::factory()->for($user)->create(['author_id' => $organization->id, 'source' => CommitSource::Web]); + Commit::factory()->for($user)->create(['author_id' => $organization->id, 'source' => null]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect(collect($data['organization_commits'])->pluck('source')->sort()->values()->all()) + ->toBe([null, 'web']); +}); + it('exports commit image urls in order', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(); From 0ea2148fac557bc4b498fb53e7ff168375019040 Mon Sep 17 00:00:00 2001 From: Sander Krul Date: Mon, 2 Nov 2026 15:30:17 +0100 Subject: [PATCH 9/9] fix: define commit mass assignment --- app/Models/Commit.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 7921addc7..edec92768 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -11,6 +11,7 @@ use App\Services\Mentions; use Carbon\CarbonInterface; use Database\Factories\CommitFactory; +use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder; use Illuminate\Database\Eloquent\Casts\AsVector; @@ -72,6 +73,20 @@ 'embedding', 'headlines_classified_at', ])] +#[Fillable([ + 'user_id', + 'author_id', + 'body', + 'source', + 'mentions', + 'parent_id', + 'root_id', + 'status', + 'link_preview_id', + 'quoted_commit_id', + 'hot_score', + 'created_at', +])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model {