From 06c6ccf90850c94421c9b4797997b3924d74e658 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:10:57 +0530 Subject: [PATCH 1/2] feat: record and display where commits were posted --- KNOWLEDGE_BASE.md | 2 + app/Actions/CreateCommit.php | 6 +- app/Actions/CreateExport.php | 6 +- app/Actions/CreateScheduledCommit.php | 4 +- app/Actions/DeleteUser.php | 4 +- .../UpdateScheduledCommitSubmittedAt.php | 2 +- app/Enums/CommitSource.php | 33 +++++++ app/Http/Controllers/Api/CommitController.php | 7 +- .../Api/ScheduledCommitController.php | 7 +- app/Http/Controllers/Cli/CommitController.php | 3 +- app/Http/Controllers/CommitController.php | 3 +- .../Controllers/CommitReplyController.php | 3 +- .../Controllers/ScheduledCommitController.php | 3 +- .../Middleware/AuthenticateSignedRequest.php | 1 + app/Http/Resources/CommitResource.php | 1 + app/Models/Commit.php | 3 + app/Models/ScheduledCommit.php | 5 +- app/Queries/CommitBuilder.php | 2 +- ...ource_to_commits_and_scheduled_commits.php | 21 +++++ resources/js/components/commit-card.tsx | 26 +++++- resources/js/types/commit.ts | 3 + tests/Feature/CommitSourceTest.php | 92 +++++++++++++++++++ .../Controllers/Cli/CommitControllerTest.php | 4 +- .../Controllers/ExportControllerTest.php | 6 +- tests/Feature/OrganizationCommitsTest.php | 4 +- tests/Unit/Actions/DeleteUserTest.php | 19 +++- tests/Unit/Models/CommitTest.php | 1 + 27 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 app/Enums/CommitSource.php create mode 100644 database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php create mode 100644 tests/Feature/CommitSourceTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d7bb9bb48..ac2d689ac 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -204,6 +204,8 @@ A **commit** is a post on /dev/null, not a Git commit in a repository. Open the Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) commits share a limit of 10 attempts per minute per user. Posts go through [moderation](#moderation), so a newly submitted commit may be pending rather than public immediately. +Commit cards show where a post was submitted when that source is known: the website, iOS, Android, macOS, the terminal, or an app whose platform is unknown. The label comes from the posting surface, not a value supplied in the post. Older posts and posts from older or unidentified apps may have no label. The source is included in the author's data export and cleared if account deletion leaves the post as a tombstone. + Approved top-level commits about technology, current affairs or substantive real-world events can receive brief, shared subject headlines. Technical and developer topics carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the subject of the post it quotes. Replies, casual small talk and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and similar newly generated headlines share the first headline rather than creating duplicates. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface. Sources: [composer](resources/js/components/commit-composer.tsx), [commit model](app/Models/Commit.php), [commit validation](app/Http/Requests/CreateCommitRequest.php), [text normalization](app/Services/CommitBodies.php), [submission throttle](app/Http/Middleware/ThrottleCommits.php). diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 9bc21f6ba..29e2a2f5b 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(); @@ -72,6 +73,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $commit = Commit::query()->create([ 'user_id' => $user->id, 'author_id' => $organization->id ?? $user->id, + 'source' => $source, 'body' => $this->emojiShortcodes->replace($body), 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 289e5abb1..07e3fd040 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -74,7 +74,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -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', 'status', 'source', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -206,6 +206,7 @@ private function commits(User $user): Generator 'body' => $commit->body, 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, + 'source' => $commit->source?->value, 'pasteis_count' => $commit->pasteis_count, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), @@ -239,6 +240,7 @@ private function organizationCommits(User $user): Generator 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, 'status' => $commit->status->value, + 'source' => $commit->source?->value, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => $commit->deleted_at?->toIso8601String(), diff --git a/app/Actions/CreateScheduledCommit.php b/app/Actions/CreateScheduledCommit.php index 6d00ed8d5..07eb5648f 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,13 +28,14 @@ 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); return DB::transaction(fn (): ScheduledCommit => ScheduledCommit::query()->create([ 'user_id' => $user->id, 'author_id' => $organization?->id, + 'source' => $source, 'body' => $body, 'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null, 'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions), diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index f1c6593bd..50fa647ee 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -68,10 +68,10 @@ public function handle(User $user): void } $user->commits()->delete(); - $user->commits()->withTrashed()->update(['user_id' => null, 'author_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null, 'headlines_classified_at' => null]); + $user->commits()->withTrashed()->update(['user_id' => null, 'author_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null, 'headlines_classified_at' => null, 'source' => null]); // What they posted for organizations is the organizations' own, so it stays, without who posted it. - Commit::query()->withTrashed()->where('user_id', $user->id)->update(['user_id' => null]); + Commit::query()->withTrashed()->where('user_id', $user->id)->update(['user_id' => null, 'source' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); 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..e58ab3bf3 --- /dev/null +++ b/app/Enums/CommitSource.php @@ -0,0 +1,33 @@ + self::Ios, + 'android' => self::Android, + 'macos' => self::Macos, + default => self::Api, + }; + } +} diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 146be1c58..a436cc392 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -6,11 +6,13 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateApiCommitRequest; use App\Http\Requests\DeleteApiCommitRequest; use App\Http\Requests\ShowApiCommitRequest; use App\Http\Resources\CommitResource; use App\Models\Commit; +use App\Models\Device; use App\Models\User; use App\Queries\CommitAncestorsQuery; use App\Queries\CommitPageQuery; @@ -51,7 +53,10 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $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()); + $device = $request->attributes->get('authenticated_device'); + $source = CommitSource::fromAppId($device instanceof Device ? $device->app_id : null); + + $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: $source); 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..778f6b082 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -6,9 +6,11 @@ use App\Actions\CreateScheduledCommit; use App\Actions\DeleteScheduledCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateApiScheduledCommitRequest; use App\Http\Requests\DeleteApiScheduledCommitRequest; use App\Http\Resources\ScheduledCommitResource; +use App\Models\Device; use App\Models\ScheduledCommit; use App\Models\User; use App\Queries\ScheduledCommitsQuery; @@ -33,7 +35,10 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule */ public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): 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()); + $device = $request->attributes->get('authenticated_device'); + $source = CommitSource::fromAppId($device instanceof Device ? $device->app_id : null); + + $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(), source: $source); 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..73e10a891 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(), source: 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/Middleware/AuthenticateSignedRequest.php b/app/Http/Middleware/AuthenticateSignedRequest.php index 76407bba7..35bbd6e66 100644 --- a/app/Http/Middleware/AuthenticateSignedRequest.php +++ b/app/Http/Middleware/AuthenticateSignedRequest.php @@ -71,6 +71,7 @@ public function handle(Request $request, Closure $next, ?string $mode = null): R throw_if($device->user->isBanned() && ! $request->routeIs('api-current-user.destroy'), ApiException::banned()); Auth::setUser($device->user); + $request->attributes->set('authenticated_device', $device); return $next($request); } diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 31dc2a058..7e4bec887 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -59,6 +59,7 @@ public function toArray(Request $request): array 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->author, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), + ...($this->source === null ? [] : ['source' => $this->source->value]), 'stars_count' => $this->whenCounted('stars'), 'replies_count' => $this->whenCounted('replies'), 'link_preview' => $this->whenLoaded('linkPreview'), diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de..c011e31ec 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; @@ -33,6 +34,7 @@ * @property-read string $body * @property-read array|null $mentions * @property-read array|null $organization_mentions + * @property-read CommitSource|null $source * @property-read CommitStatus $status * @property-read string|null $link_preview_id * @property-read string|null $quoted_commit_id @@ -127,6 +129,7 @@ public function casts(): array 'mentions' => 'array', 'organization_mentions' => 'array', 'status' => CommitStatus::class, + 'source' => CommitSource::class, 'link_preview_id' => 'string', 'quoted_commit_id' => 'string', 'ai_decision' => CommitStatus::class, diff --git a/app/Models/ScheduledCommit.php b/app/Models/ScheduledCommit.php index 5bd7170ce..9f18e5518 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; @@ -17,6 +18,7 @@ * @property-read string $user_id * @property-read string|null $author_id * @property-read string|null $commit_id + * @property-read CommitSource|null $source * @property-read string|null $body * @property-read array{options: list, duration: int}|null $poll * @property-read list|null $images @@ -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', 'poll', 'images', 'link_preview_url', 'source', 'scheduled_at'])] final class ScheduledCommit extends Model { /** @use HasFactory */ @@ -41,6 +43,7 @@ public function casts(): array { return [ 'poll' => 'array', + 'source' => CommitSource::class, 'images' => 'array', 'scheduled_at' => 'immutable_datetime', 'submitted_at' => 'immutable_datetime', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 9ce30e753..2a3dd54cf 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', 'created_at', 'updated_at', 'status', 'source', '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/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php b/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php new file mode 100644 index 000000000..ca750225e --- /dev/null +++ b/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php @@ -0,0 +1,21 @@ +string('source', 16)->nullable(); + }); + + Schema::table('scheduled_commits', function (Blueprint $table): void { + $table->string('source', 16)->nullable(); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 000353a52..d2d982391 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -68,7 +68,21 @@ import { shortHash, } from '@/lib/utils'; import { show } from '@/routes/user'; -import type { Commit, CursorPaginated, ThreadCommit } from '@/types'; +import type { + Commit, + CommitSource, + CursorPaginated, + ThreadCommit, +} from '@/types'; + +const sourceLabels: Record = { + web: 'Web', + ios: 'iOS', + android: 'Android', + macos: 'macOS', + cli: 'CLI', + api: 'App', +}; export function CommitCard({ commit, @@ -399,6 +413,16 @@ export function CommitCard({ + {commit.source && ( + + Β· via {sourceLabels[commit.source]} + + )} + diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 73c8df690..b74b48ea3 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -3,6 +3,8 @@ import type { Organization } from '@/types/organization'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; +export type CommitSource = 'web' | 'ios' | 'android' | 'macos' | 'cli' | 'api'; + export type EmbedKind = 'video' | 'audio' | 'code'; export type Embed = { @@ -95,6 +97,7 @@ export type Commit = { preview_segments: BodySegment[] | null; translation?: CommitTranslation | null; status: CommitStatus; + source?: CommitSource | null; is_checking?: boolean; created_at: string; updated_at: string; diff --git a/tests/Feature/CommitSourceTest.php b/tests/Feature/CommitSourceTest.php new file mode 100644 index 000000000..b2a4da48b --- /dev/null +++ b/tests/Feature/CommitSourceTest.php @@ -0,0 +1,92 @@ +create(); + $parent = Commit::factory()->create(); + $browser = ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)']; + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Created in Safari.', + 'source' => 'ios', + ], $browser)->assertSessionHasNoErrors(); + + $this->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Replied in Safari.', + 'source' => 'android', + ], $browser)->assertCreated(); + + expect($user->commits()->oldest()->get()->pluck('source')->all()) + ->toBe([CommitSource::Web, CommitSource::Web]); +}); + +it('records the approved app platform rather than a client-provided source', function (?string $appId, CommitSource $source): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + Device::query()->where('user_id', $user->id)->sole()->forceFill(['app_id' => $appId])->save(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Posted from the app.', + 'source' => 'web', + ])->assertCreated()->assertJsonPath('data.source', $source->value); + + expect($user->commits()->sole()->source)->toBe($source); +})->with([ + 'iOS' => ['ios:sh.dev-null.app', CommitSource::Ios], + 'Android' => ['android:sh.dev-null.app', CommitSource::Android], + 'macOS' => ['macos:sh.dev-null.mac', CommitSource::Macos], + 'older app without an id' => [null, CommitSource::Api], + 'unknown app platform' => ['windows:sh.dev-null.app', CommitSource::Api], +]); + +it('keeps the original posting source when a web commit is scheduled', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('scheduled-commit.store'), [ + 'body' => 'Scheduled from the browser.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertSessionHasNoErrors(); + + $scheduled = ScheduledCommit::query()->sole(); + + expect($scheduled->source)->toBe(CommitSource::Web); + + expect(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeTrue(); + expect(Commit::query()->sole()->source)->toBe(CommitSource::Web); +}); + +it('keeps the approved app platform when a native commit is scheduled', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + Device::query()->where('user_id', $user->id)->sole()->forceFill(['app_id' => 'android:sh.dev-null.app'])->save(); + + signedApiRequest($keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Scheduled from Android.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertCreated(); + + $scheduled = ScheduledCommit::query()->sole(); + + expect($scheduled->source)->toBe(CommitSource::Android); + + expect(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeTrue(); + expect(Commit::query()->sole()->source)->toBe(CommitSource::Android); +}); + +it('keeps the source of existing commits unknown without guessing', function (): void { + $commit = Commit::factory()->create(); + + expect($commit->source)->toBeNull(); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.source'); +}); 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/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 60160eaca..c88da74f1 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; @@ -90,7 +91,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -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.", 'pasteis_count' => 3, 'source' => CommitSource::Web]); $approved->headlines()->create(['name' => 'Unicode support ships']); $pending = Commit::factory()->for($user)->pending()->create(); $rejected = Commit::factory()->for($user)->rejected()->create(); @@ -249,6 +250,7 @@ 'expires_at' => now()->addDays(7)->getTimestamp(), ], 'status' => 'approved', + 'source' => 'web', 'pasteis_count' => 3, 'images' => [], 'created_at' => $approved->created_at->toIso8601String(), diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index f01494032..b4a477c18 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -8,6 +8,7 @@ use App\Actions\DeleteOrganization; use App\Actions\DeleteUser; use App\Actions\UpdateCommitStatuses; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\Feature; use App\Enums\Log; @@ -179,7 +180,7 @@ }); it('exports the commits someone posted for an organization', function (): void { - $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Laravel 13 is out']); + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Laravel 13 is out', 'source' => CommitSource::Ios]); $export = resolve(CreateExport::class)->handle($this->member); @@ -190,6 +191,7 @@ 'quoted_commit_id' => null, 'body' => 'Laravel 13 is out', 'status' => 'approved', + 'source' => 'ios', 'images' => [], 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => null, diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 400954891..baa9f2027 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -9,6 +9,7 @@ use App\Actions\UpdateCommitHotScores; use App\Contracts\Services\TranslationProvider; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\OrganizationRole; use App\Models\Commit; use App\Models\CommitImage; @@ -52,6 +53,11 @@ $reply = Commit::factory()->for($commit, 'parent')->create(); $unrelated = Commit::factory()->create(); $organization = Organization::factory()->create(); + $organizationCommit = Commit::factory()->create([ + 'user_id' => $user->id, + 'author_id' => $organization->id, + 'source' => CommitSource::Android, + ]); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($unrelated, ['laravel' => $organization->id]); @@ -61,7 +67,10 @@ resolve(DeleteUser::class)->handle($user); - expect(Commit::withTrashed()->pluck('id')->all())->toBe([$unrelated->id]) + expect(Commit::withTrashed()->pluck('id')->sort()->values()->all())->toBe(collect([$unrelated->id, $organizationCommit->id])->sort()->values()->all()) + ->and($organizationCommit->refresh()->user_id)->toBeNull() + ->and($organizationCommit->author_id)->toBe($organization->id) + ->and($organizationCommit->source)->toBeNull() ->and($headline->commits->modelKeys())->toBe([$unrelated->id]) ->and(DB::table('commit_topics')->pluck('commit_id')->all())->toBe([$unrelated->id]); }); @@ -105,6 +114,7 @@ 'mentions' => [$mentioned->handle => $mentioned->id], 'embedding' => embeddingVector(0), 'headlines_classified_at' => now(), + 'source' => CommitSource::Android, ]); resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => Organization::factory()->create()->id]); $reply->headlines()->create(['name' => 'Laravel adds a new feature']); @@ -124,7 +134,8 @@ ->and($reply->headlines()->exists())->toBeFalse() ->and($tombstones->pluck('embedding')->all())->toBe([null, null]) ->and($tombstones->pluck('headlines_classified_at')->all())->toBe([null, null]) - ->and($tombstones->pluck('link_preview_id')->all())->toBe([null, null]); + ->and($tombstones->pluck('link_preview_id')->all())->toBe([null, null]) + ->and($tombstones->pluck('source')->all())->toBe([null, null]); }); it('erases what deleted accounts wrote from the tombstones they already left', function (): void { @@ -331,7 +342,7 @@ Storage::fake(); $user = User::factory()->create(); - $commits = Commit::factory()->for($user)->count(10)->create(); + $commits = Commit::factory()->for($user)->count(11)->create(); $commits->each(fn (Commit $commit): Commit => Commit::factory()->for($commit, 'parent')->create()); $repliedTo = Commit::factory()->create(); @@ -341,7 +352,7 @@ resolve(DeleteUser::class)->handle($user); - expect(collect(DB::getQueryLog())->max(fn (array $query): int => count($query['bindings'])))->toBeLessThan(10) + expect(collect(DB::getQueryLog())->max(fn (array $query): int => count($query['bindings'])))->toBeLessThan($commits->count()) ->and(Commit::query()->pluck('id')->sort()->values()->all())->toBe(collect([$repliedTo->id, $otherReply->id])->sort()->values()->all()); }); diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 17baaae31..a6d7a6fcd 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -21,6 +21,7 @@ 'created_at', 'updated_at', 'status', + 'source', 'parent_id', 'deleted_at', 'link_preview_id', From b78c1294ff957441d80488a77682aea1a507f555 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:04:48 +0530 Subject: [PATCH 2/2] docs: clarify legacy scheduled commit source labels --- KNOWLEDGE_BASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index ac2d689ac..cd982fd77 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -204,7 +204,7 @@ A **commit** is a post on /dev/null, not a Git commit in a repository. Open the Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) commits share a limit of 10 attempts per minute per user. Posts go through [moderation](#moderation), so a newly submitted commit may be pending rather than public immediately. -Commit cards show where a post was submitted when that source is known: the website, iOS, Android, macOS, the terminal, or an app whose platform is unknown. The label comes from the posting surface, not a value supplied in the post. Older posts and posts from older or unidentified apps may have no label. The source is included in the author's data export and cleared if account deletion leaves the post as a tombstone. +Commit cards show where a post was submitted when that source is known: the website, iOS, Android, macOS, the terminal, or an app whose platform is unknown. The label comes from the posting surface, not a value supplied in the post. Older posts and posts scheduled before source tracking was added may have no label, even when submitted later. New posts from apps without a recognized platform are labeled β€œvia App.” The source is included in the author's data export and cleared if account deletion leaves the post as a tombstone. Approved top-level commits about technology, current affairs or substantive real-world events can receive brief, shared subject headlines. Technical and developer topics carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the subject of the post it quotes. Replies, casual small talk and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and similar newly generated headlines share the first headline rather than creating duplicates. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface.