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/5] 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/5] 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. From 036cffec62e02ed0363d666bdd602df7f6a9d298 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:02:13 +0530 Subject: [PATCH 3/5] refactor: share posting-source detection with app usage --- KNOWLEDGE_BASE.md | 2 +- app/Enums/CommitSource.php | 31 ++++++++++------- app/Http/Controllers/Api/CommitController.php | 4 +-- .../Api/ScheduledCommitController.php | 4 +-- app/Http/Controllers/CommitController.php | 2 +- .../Controllers/CommitReplyController.php | 2 +- .../Controllers/ScheduledCommitController.php | 2 +- .../Middleware/AuthenticateSignedRequest.php | 1 - resources/js/components/commit-card.tsx | 1 + resources/js/types/commit.ts | 2 +- tests/Feature/CommitSourceTest.php | 20 ++++++++--- tests/Unit/Enums/CommitSourceTest.php | 33 +++++++++++++++++++ 12 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/Enums/CommitSourceTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3200dbf7c..2145e74ee 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -232,7 +232,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 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. +Commit cards show where a post was submitted when that source is known: the website, iOS, Android, the lite mobile app, macOS, the terminal, or an app whose platform is unknown. Posting sources reuse the same request classification as daily app-usage metrics: NativePHP is identified by its approved device ID, the Mac app by its user agent, and the lite app by its device ID or web-view user agent. Only recognized NativePHP devices are further distinguished as iOS or Android. 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 specific developments in technology, current affairs or substantive real-world events can receive brief, shared headlines. A headline identifies a particular release, feature, event, change, problem or finding, not a broad category such as "Laravel" or "AI tools". Technical and developer developments 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 development of the post it quotes. Replies are not used for headline classification. Casual small talk, broad technology chat without a specific development, 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 newly generated headlines about the same development share the first headline rather than creating duplicates. Different developments involving the same product do not share a headline merely because of that product. 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. diff --git a/app/Enums/CommitSource.php b/app/Enums/CommitSource.php index e58ab3bf3..73baa1de6 100644 --- a/app/Enums/CommitSource.php +++ b/app/Enums/CommitSource.php @@ -4,30 +4,39 @@ namespace App\Enums; +use App\Models\Device; +use Illuminate\Http\Request; + /** - * Where a commit was created, not the operating system of a web browser. - * Native apps identify themselves when the user approves their device. + * Where a commit was created. It reuses the app classification that also + * counts daily app usage, then distinguishes supported mobile platforms. */ enum CommitSource: string { case Web = 'web'; case Ios = 'ios'; case Android = 'android'; + case LiteMobile = 'lite_mobile'; case Macos = 'macos'; case Cli = 'cli'; case Api = 'api'; - public static function fromAppId(?string $appId): self + /** The request determines the source; a commit payload cannot choose it. */ + public static function forRequest(Request $request): self { - if ($appId === null || ! str_contains($appId, ':')) { - return self::Api; - } + $device = $request->attributes->get(Device::class); + $appId = $device instanceof Device ? $device->app_id : null; - return match (explode(':', $appId, 2)[0]) { - 'ios' => self::Ios, - 'android' => self::Android, - 'macos' => self::Macos, - default => self::Api, + return match (ActiveDayApp::forRequest($request)) { + ActiveDayApp::Web => self::Web, + ActiveDayApp::LiteMobile => self::LiteMobile, + ActiveDayApp::Mac => self::Macos, + ActiveDayApp::Other => self::Api, + ActiveDayApp::NativeMobile => match (explode(':', (string) $appId, 2)[0]) { + 'ios' => self::Ios, + 'android' => self::Android, + default => self::Api, + }, }; } } diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index a436cc392..843f35418 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -12,7 +12,6 @@ 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; @@ -53,8 +52,7 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $device = $request->attributes->get('authenticated_device'); - $source = CommitSource::fromAppId($device instanceof Device ? $device->app_id : null); + $source = CommitSource::forRequest($request); $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); diff --git a/app/Http/Controllers/Api/ScheduledCommitController.php b/app/Http/Controllers/Api/ScheduledCommitController.php index 778f6b082..f6496f9de 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -10,7 +10,6 @@ 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; @@ -35,8 +34,7 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule */ public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse { - $device = $request->attributes->get('authenticated_device'); - $source = CommitSource::fromAppId($device instanceof Device ? $device->app_id : null); + $source = CommitSource::forRequest($request); $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); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index fcd5a4bc5..12408e574 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -69,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, source: CommitSource::Web); + $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::forRequest($request)); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index b66114d7c..357d4805c 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -41,7 +41,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, source: CommitSource::Web); + $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::forRequest($request)); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 867b6a817..a94b748c3 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -35,7 +35,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(), source: CommitSource::Web); + $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::forRequest($request)); 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 e2b4051e1..cd23e2d49 100644 --- a/app/Http/Middleware/AuthenticateSignedRequest.php +++ b/app/Http/Middleware/AuthenticateSignedRequest.php @@ -72,7 +72,6 @@ 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); $request->attributes->set(Device::class, $device); return $next($request); diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 173cc2f75..f192a3882 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -74,6 +74,7 @@ const sourceLabels: Record = { web: 'Web', ios: 'iOS', android: 'Android', + lite_mobile: 'Lite', macos: 'macOS', cli: 'CLI', api: 'App', diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index b74b48ea3..0b8b2e0b4 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -3,7 +3,7 @@ import type { Organization } from '@/types/organization'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; -export type CommitSource = 'web' | 'ios' | 'android' | 'macos' | 'cli' | 'api'; +export type CommitSource = 'web' | 'ios' | 'android' | 'lite_mobile' | 'macos' | 'cli' | 'api'; export type EmbedKind = 'video' | 'audio' | 'code'; diff --git a/tests/Feature/CommitSourceTest.php b/tests/Feature/CommitSourceTest.php index b2a4da48b..a4996fae4 100644 --- a/tests/Feature/CommitSourceTest.php +++ b/tests/Feature/CommitSourceTest.php @@ -28,6 +28,17 @@ ->toBe([CommitSource::Web, CommitSource::Web]); }); + +it('recognizes the lite mobile web view as distinct from a normal browser', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->withHeader('User-Agent', 'Mozilla/5.0 DevNullLite/1.0.0') + ->post(route('commit.store'), ['body' => 'Sent from the lite app.']) + ->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->source)->toBe(CommitSource::LiteMobile); +}); + 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); @@ -40,9 +51,10 @@ 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], + 'iOS NativePHP' => ['ios:com.nativephp.devnull', CommitSource::Ios], + 'Android NativePHP' => ['android:com.nativephp.devnull', CommitSource::Android], + 'unrecognized iOS app' => ['ios:sh.dev-null.app', CommitSource::Api], + 'mac app id alone is not enough' => ['macos:sh.dev-null.mac', CommitSource::Api], 'older app without an id' => [null, CommitSource::Api], 'unknown app platform' => ['windows:sh.dev-null.app', CommitSource::Api], ]); @@ -66,7 +78,7 @@ 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(); + Device::query()->where('user_id', $user->id)->sole()->forceFill(['app_id' => 'android:com.nativephp.devnull'])->save(); signedApiRequest($keyPair, 'POST', route('api-scheduled-commit.store'), [ 'body' => 'Scheduled from Android.', diff --git a/tests/Unit/Enums/CommitSourceTest.php b/tests/Unit/Enums/CommitSourceTest.php new file mode 100644 index 000000000..783e909af --- /dev/null +++ b/tests/Unit/Enums/CommitSourceTest.php @@ -0,0 +1,33 @@ + $agent]); + + if ($appId !== null) { + $request->attributes->set(Device::class, new Device(['app_id' => $appId])); + } + + expect(ActiveDayApp::forRequest($request))->toBe($app) + ->and(CommitSource::forRequest($request))->toBe($source); +})->with([ + 'web browser' => ['/log/latest', null, 'Mozilla/5.0 (iPhone)', ActiveDayApp::Web, CommitSource::Web], + 'mac user-agent on a web route is still web' => ['/log/latest', null, 'DevNull-Mac/1.0', ActiveDayApp::Web, CommitSource::Web], + 'lite web view' => ['/log/latest', null, 'Mozilla/5.0 DevNullLite/1.0', ActiveDayApp::LiteMobile, CommitSource::LiteMobile], + 'native iOS app' => ['/api/v1/me', 'ios:com.nativephp.devnull', null, ActiveDayApp::NativeMobile, CommitSource::Ios], + 'native Android app' => ['/api/v1/me', 'android:com.nativephp.devnull', null, ActiveDayApp::NativeMobile, CommitSource::Android], + 'lite signed app' => ['/api/v1/me', MobileAuthorization::LiteMobileAppId, null, ActiveDayApp::LiteMobile, CommitSource::LiteMobile], + 'mac desktop user-agent' => ['/api/v1/me', null, 'DevNull-Mac/1.0', ActiveDayApp::Mac, CommitSource::Macos], + 'mac app id alone without user-agent' => ['/api/v1/me', 'macos:sh.dev-null.mac', null, ActiveDayApp::Other, CommitSource::Api], + 'unrecognized iOS app' => ['/api/v1/me', 'ios:sh.dev-null.app', null, ActiveDayApp::Other, CommitSource::Api], + 'older device without an app id' => ['/api/v1/me', null, 'curl/8', ActiveDayApp::Other, CommitSource::Api], + 'spoofed lite user-agent on API' => ['/api/v1/me', null, 'DevNullLite/1.0', ActiveDayApp::Other, CommitSource::Api], + 'other app' => ['/api/v1/me', 'windows:sh.dev-null.app', null, ActiveDayApp::Other, CommitSource::Api], +]); From 837103a5f220c2e68dd2f41e4331e567bb3065ec Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:09:18 +0530 Subject: [PATCH 4/5] docs: describe commit source posting paths --- app/Http/Controllers/Cli/CommitController.php | 3 +++ app/Http/Controllers/CommitController.php | 3 +++ app/Http/Controllers/ScheduledCommitController.php | 3 +++ resources/js/components/commit-card.tsx | 1 + 4 files changed, 10 insertions(+) diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index 2814a86b3..610da058f 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -14,6 +14,9 @@ final readonly class CommitController { + /** + * Creates a terminal commit and returns its URL, keeping the CLI source separate from signed app posts. + */ 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 12408e574..41ce6d3ef 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -64,6 +64,9 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs ])->withViewData(['preview' => $preview]); } + /** + * Creates a web commit with its request-derived source, then flashes the commit and any new achievements. + */ 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 a94b748c3..9490129de 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -33,6 +33,9 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm ]); } + /** + * Schedules a post with the current app source so it can retain that source when published later. + */ 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(), source: CommitSource::forRequest($request)); diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index f192a3882..a02dd1cc4 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -80,6 +80,7 @@ const sourceLabels: Record = { api: 'App', }; +/** Draws a commit with its thread graph, actions, and posting source when available. */ export function CommitCard({ commit, previousLane, From 8a7ab8e82853a5207805a86a52bff377c4007480 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:10:41 +0530 Subject: [PATCH 5/5] docs: complete controller action descriptions for posting source PR --- app/Http/Controllers/Api/CommitController.php | 1 + app/Http/Controllers/CommitController.php | 1 + app/Http/Controllers/CommitReplyController.php | 1 + app/Http/Controllers/ScheduledCommitController.php | 2 ++ 4 files changed, 5 insertions(+) diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 843f35418..0c47ddd77 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -61,6 +61,7 @@ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user ->setStatusCode(201); } + /** Deletes an API commit and returns an empty success response. */ public function destroy(DeleteApiCommitRequest $request, Commit $commit, DeleteCommit $action): Response { $action->handle($commit); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 41ce6d3ef..c6b536261 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -96,6 +96,7 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C return back(); } + /** Deletes a commit and returns its restoration window to the frontend. */ public function destroy(DeleteCommitRequest $request, Commit $commit, DeleteCommit $action): RedirectResponse { $commit = $action->handle($commit); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index 357d4805c..f27304a4a 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -62,6 +62,7 @@ public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $us ], 201); } + /** Deletes a reply and returns its updated state to the thread. */ public function destroy(DeleteCommitReplyRequest $request, Commit $commit, Commit $reply, DeleteCommit $action): JsonResponse { $reply = $action->handle($reply); diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 9490129de..df9924d76 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -25,6 +25,7 @@ final readonly class ScheduledCommitController { + /** Lists the user's scheduled posts with live updates when the queue changes. */ public function index(Request $request, #[CurrentUser] User $user, ScheduledCommitsQuery $scheduled): Response { return Inertia::render('scheduled-commits/index', [ @@ -46,6 +47,7 @@ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User return back(); } + /** Cancels a scheduled post, or explains why it can no longer be canceled. */ public function destroy(DeleteScheduledCommitRequest $request, #[CurrentUser] User $user, ScheduledCommit $scheduledCommit, DeleteScheduledCommit $action): RedirectResponse { $canceled = $action->handle($user, $scheduledCommit);