From 27b25336d8a255cd8c1f43556675f54a9a015412 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Tue, 27 Oct 2026 18:33:12 +0000 Subject: [PATCH 1/2] feat: list shipped commits on a profile Fixes #1028 --- KNOWLEDGE_BASE.md | 10 +- app/Actions/CreateCommit.php | 6 +- app/Actions/CreateExport.php | 3 +- app/Http/Controllers/CommitController.php | 2 +- app/Http/Controllers/UserController.php | 2 +- .../Requests/CreateCommitReplyRequest.php | 1 + app/Http/Requests/CreateCommitRequest.php | 6 ++ app/Http/Requests/ShowUserRequest.php | 3 +- app/Http/Resources/CommitResource.php | 4 +- app/Models/Commit.php | 11 +- app/Queries/CommitBuilder.php | 2 +- app/Queries/UserCommitsQuery.php | 14 ++- ...27_174500_add_shipped_to_commits_table.php | 17 +++ resources/js/components/commit-card.tsx | 9 ++ resources/js/components/commit-composer.tsx | 30 ++++++ resources/js/pages/user/show.tsx | 23 +++- resources/js/types/commit.ts | 1 + .../Controllers/Api/CommitControllerTest.php | 24 +++++ .../Controllers/CommitControllerTest.php | 66 ++++++++++++ .../Controllers/CommitReplyControllerTest.php | 11 ++ .../Controllers/ExportControllerTest.php | 1 + .../Controllers/UserShippedCommitsTest.php | 102 ++++++++++++++++++ tests/Unit/Actions/CreateCommitTest.php | 23 +++- tests/Unit/ComposerPreview.test.tsx | 9 ++ tests/Unit/Models/CommitTest.php | 8 ++ tests/Unit/Queries/UserCommitsQueryTest.php | 25 +++++ 26 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2026_10_27_174500_add_shipped_to_commits_table.php create mode 100644 tests/Feature/Controllers/UserShippedCommitsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d..2be6ded9e 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,7 +98,7 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes ### Profiles and GitHub sync -Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. +Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Shipped lists the ones marked as something that developer shipped; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged. @@ -180,9 +180,11 @@ Livestream history: the team agreed to build semantic search on [day two at 2:57 A **commit** is a post on /dev/null, not a Git commit in a repository. Open the composer or press **c**, write your update, and choose **Commit** or press **⌘/Ctrl+Enter**. Text-only commits accept 1–4,000 characters; as you type, the ring around your avatar in the composer fills toward that limit. On the web and in the app, an image attachment makes the text optional (terminal commits still need text), but the 4,000-character maximum still applies. Runs of blank lines outside code collapse to one before the length is checked. Lists fold posts longer than 280 characters or 8 lines behind **Show more**, without cutting through code. Commits can be created and deleted, but not edited after publication. There are no plans to add commit editing, and it will never be added: to change a commit, delete it and post a new one. +Check **Shipped** in the web composer when a commit is something you shipped. The mark is chosen when you post and cannot be changed later. Replies and quotes cannot be marked shipped. The **Shipped** tab on a profile lists that developer's shipped commits. Other people see the approved ones. You also see your own pending and rejected shipped commits, the same way the Commits tab works. Terminal commits, the GitHub Action, and the signed API do not offer this mark. + 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. -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). +Sources: [composer](resources/js/components/commit-composer.tsx), [profile](resources/js/pages/user/show.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). Livestream history: the team settled on calling posts **commits** and their list a **log** on [day one at 2:56:00](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10560s). At [3:02:19](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10939s), they agreed to rename the post model too, rather than changing only the UI. These are naming decisions, not timestamps proving the rename had finished. @@ -246,7 +248,7 @@ Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-car ### Replies and threads -Reply to a visible commit to continue its thread. Replies use the same text limits, image support, moderation, and submission rate limit as commits. Threads expand inline, a few replies at a time: choose 1, 3, 5, 10, 15, 20, or 25 in Settings → Appearance (3 by default). Opening a commit's own page shows its ancestors and replies. Quotes of a commit are listed among its replies and counted with them. Profiles have a separate Replies tab for authored replies. A reply cannot also be a quote, contain a poll, or be scheduled. +Reply to a visible commit to continue its thread. Replies use the same text limits, image support, moderation, and submission rate limit as commits. Threads expand inline, a few replies at a time: choose 1, 3, 5, 10, 15, 20, or 25 in Settings → Appearance (3 by default). Opening a commit's own page shows its ancestors and replies. Quotes of a commit are listed among its replies and counted with them. Profiles have a separate Replies tab for authored replies. A reply cannot also be a quote, contain a poll, be scheduled, or be marked shipped. When an author replies to their own commit, their earliest such reply continues it as a thread. The commit's page reads that thread in order, 10 commits at a time with **Show more of this thread**, each marked with its place, such as `3/7`. Elsewhere, a continued commit shows **Show thread**, which opens its page. The thread stops at the first reply you cannot see, and is hidden if you muted the author. @@ -264,7 +266,7 @@ Sources: [related commits query](app/Queries/RelatedCommitsQuery.php), [related ### Quoting commits -Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. +Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll, scheduling, or shipped mark. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. The embedded original only shows content the viewer may see. Deleted originals appear as `[deleted]`; otherwise-inaccessible originals appear as `[unavailable]` without exposing their author or body. There is no comment-free repost action. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..6749a9a75 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -46,8 +46,9 @@ public function __construct( * * @param list $images * @param array $imageDescriptions + * @param bool $shipped Only a top-level commit that is not a quote can be marked shipped. */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = 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, bool $shipped = false): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -55,7 +56,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): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $shipped): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -68,6 +69,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'status' => $user->isAdmin() || $hasGoodReputation ? CommitStatus::Approved : CommitStatus::Pending, 'link_preview_id' => $linkPreview?->id, 'quoted_commit_id' => $quotedCommit?->id, + 'shipped' => $parent === null && $quotedCommit === null && $shipped, 'hot_score' => $this->theAlgorithm->score($createdAt, ThreadEngagement::none()), 'created_at' => $createdAt, ]); diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index c8c9e48c5..bd485504c 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -166,7 +166,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', 'pasteis_count', 'shipped', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -184,6 +184,7 @@ private function commits(User $user): Generator 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, 'pasteis_count' => $commit->pasteis_count, + 'shipped' => $commit->shipped, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), 'updated_at' => $commit->updated_at->toIso8601String(), diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index b6ad709bc..1b3194bb2 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -66,7 +66,7 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C { $before = $achievements->unlockedBadgeNames($user); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll()); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), shipped: $request->shipped()); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 348ec66ae..4f27c5c2d 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -75,7 +75,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser // The README tab shows no commits, so it doesn't look for any. 'commits' => Inertia::scroll(fn () => $request->tab() === 'readme' ? new CursorPaginator([], 20) - : $commits->builder($user, $currentUser, $request->tab() === 'replies') + : $commits->builder($user, $currentUser, $request->tab() === 'replies', $request->tab() === 'shipped') ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->user($getCountedUser())]); diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index da3487990..bd788082a 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -46,6 +46,7 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), + 'shipped' => ['prohibited'], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], 'images' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'images.*' => [ diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 338db8c30..d1b1de1d8 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -89,6 +89,7 @@ public function rules(): array return [ 'body' => self::bodyRules($this->hasFile('images')), 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], + 'shipped' => ['sometimes', 'boolean', 'prohibits:parent_id,quoted_commit_id'], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], ...$attachmentRules, ]; @@ -126,6 +127,11 @@ public function messages(): array ]; } + public function shipped(): bool + { + return $this->boolean('shipped'); + } + /** * The first link in the body gets the link preview, unless the author chose another link or none at all. */ diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php index a6cef64f6..acadd65fc 100644 --- a/app/Http/Requests/ShowUserRequest.php +++ b/app/Http/Requests/ShowUserRequest.php @@ -19,7 +19,7 @@ public function authorize(): bool public function rules(): array { return [ - 'tab' => ['nullable', 'string', 'in:commits,replies,readme'], + 'tab' => ['nullable', 'string', 'in:commits,replies,readme,shipped'], ]; } @@ -28,6 +28,7 @@ public function tab(): string return match ($this->input('tab')) { 'replies' => 'replies', 'readme' => 'readme', + 'shipped' => 'shipped', default => 'commits', }; } diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 6ce3b53b2..1c6198a22 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -24,7 +24,8 @@ * and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. Like the web's cards, * it also has preview_segments, the excerpt a list shows of a long commit, the poll, with what the user may see of * its votes, and the commit it quotes, or null once the user may no longer see that one, while quoted_commit_id still - * says it quoted something. In a list, reply_preview is the newest reply or quote the web shows under it. As on the + * says it quoted something. When the author marked it as something they shipped, shipped is true. Otherwise that key + * is left out. In a list, reply_preview is the newest reply or quote the web shows under it. As on the * web, the counts, link_preview, images, starred, parent, poll, quoted_commit and reply_preview are only * there when they were loaded, so ancestors and the commits of notifications come without some of them. The images of * a commit that isn't published yet have temporary URLs, since the app has no web session to load them with. @@ -66,6 +67,7 @@ public function toArray(Request $request): array ? $this->images : $this->images->map(fn (CommitImage $image): array => [...$image->toArray(), 'url' => $image->temporaryUrl()])), 'author' => UserResource::make($this->user), + 'shipped' => $this->when($this->shipped, true), ]; if (! $isSigned) { diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 5e0da8e6e..3d12c66ab 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -41,6 +41,7 @@ * @property-read CarbonInterface|null $deleted_at * @property-read int $stars_count * @property-read int $pasteis_count + * @property-read bool $shipped * @property-read bool $starred * @property-read User|null $user * @property-read Collection $stars @@ -93,6 +94,7 @@ final class Commit extends Model */ protected $attributes = [ 'pasteis_count' => 0, + 'shipped' => false, ]; /** @@ -112,6 +114,7 @@ public function casts(): array 'root_id' => 'string', 'hot_score' => 'float', 'pasteis_count' => 'integer', + 'shipped' => 'boolean', 'body' => 'string', 'mentions' => 'array', 'status' => CommitStatus::class, @@ -145,7 +148,13 @@ public function toArray(): array $this->segments = resolve(CommitBodies::class)->segments($this->body); } - return [...parent::toArray(), ...$this->segments, 'is_checking' => $this->isBeingChecked()]; + $commit = [...parent::toArray(), ...$this->segments, 'is_checking' => $this->isBeingChecked()]; + + if (($commit['shipped'] ?? false) !== true) { + unset($commit['shipped']); + } + + return $commit; } /** diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 4243256ec..6ace5d705 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -27,7 +27,7 @@ final class CommitBuilder extends Builder */ private const array COLUMNS_BUT_EMBEDDING = [ 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', - 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', + 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', 'shipped', ]; /** diff --git a/app/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php index 6d0284520..540f38940 100644 --- a/app/Queries/UserCommitsQuery.php +++ b/app/Queries/UserCommitsQuery.php @@ -11,11 +11,11 @@ final readonly class UserCommitsQuery { /** - * The commits on the Commits or Replies tab of a user's profile, as the viewer, or a guest, may see them. The - * Commits tab leaves out the pinned commit, which shows above it, and the Replies tab comes with the parents the - * viewer may see. + * The commits on the Commits, Shipped, or Replies tab of a user's profile, as the viewer, or a guest, may see + * them. The Commits tab leaves out the pinned commit, which shows above it. Shipped lists top-level commits + * marked shipped, including one that is pinned. The Replies tab comes with the parents the viewer may see. */ - public function builder(User $user, ?User $viewer, bool $replies = false): CommitBuilder + public function builder(User $user, ?User $viewer, bool $replies = false, bool $shipped = false): CommitBuilder { return Commit::query() ->whereBelongsTo($user) @@ -28,7 +28,11 @@ public function builder(User $user, ?User $viewer, bool $replies = false): Commi ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) ->with('user')]), fn (CommitBuilder $query): CommitBuilder => $query->topLevel() - ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), + ->when($shipped, fn (CommitBuilder $query): CommitBuilder => $query->where('shipped', true)) + ->when( + $user->pinned_commit_id, + fn (CommitBuilder $query, string $id): CommitBuilder => $shipped ? $query : $query->whereKeyNot($id), + ), ) ->withVisibleReplyCount($viewer) ->withReplyPreviews($viewer) diff --git a/database/migrations/2026_10_27_174500_add_shipped_to_commits_table.php b/database/migrations/2026_10_27_174500_add_shipped_to_commits_table.php new file mode 100644 index 000000000..35f9f163e --- /dev/null +++ b/database/migrations/2026_10_27_174500_add_shipped_to_commits_table.php @@ -0,0 +1,17 @@ +boolean('shipped')->default(false); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 80b7ae126..c31cfe57a 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -387,6 +387,15 @@ export function CommitCard({ + {commit.shipped && ( + + Shipped + + )} + (null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); const isIssue = issue.command !== null; + const [shipped, setShipped] = useState(false); // When the dialog's text box shrinks, as the keyboard opens or an image is added, it keeps the caret in view. useEffect(() => { @@ -404,6 +407,33 @@ function CommitComposerBody({ )} + {!isIssue && !isScheduling && ( +
+ + setShipped(checked === true) + } + className="mt-0.5" + data-test="shipped-checkbox" + /> + {shipped && ( + + )} + +
+ )} +
{!isIssue && ( diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 28cc13e3b..78cb9618a 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -89,7 +89,7 @@ export default function Show({ streaks: Streaks; achievements: Achievements; commits: CursorPaginated; - tab: 'commits' | 'replies' | 'readme'; + tab: 'commits' | 'replies' | 'readme' | 'shipped'; pinnedCommit: Commit | null; readme: string | false | null; }) { @@ -442,6 +442,11 @@ export default function Show({ href: show(user), isActive: tab === 'commits', }, + { + title: 'Shipped', + href: show(user, { query: { tab: 'shipped' } }), + isActive: tab === 'shipped', + }, { title: 'Replies', href: show(user, { query: { tab: 'replies' } }), @@ -484,9 +489,13 @@ export default function Show({ ? isCurrentUser ? 'That was your first reply.' : `That's @${user.handle}'s first reply.` - : isCurrentUser - ? 'That was your first commit. Everyone starts somewhere.' - : `That's @${user.handle}'s root commit.` + : tab === 'shipped' + ? isCurrentUser + ? 'That was your first shipped commit.' + : `That's @${user.handle}'s first shipped commit.` + : isCurrentUser + ? 'That was your first commit. Everyone starts somewhere.' + : `That's @${user.handle}'s root commit.` } empty={ tab === 'replies' ? ( @@ -495,6 +504,12 @@ export default function Show({ ? 'Your replies show up here. Join a conversation from your log.' : `@${user.handle} hasn't replied to anything yet.`} + ) : tab === 'shipped' ? ( + + {isCurrentUser + ? 'Your shipped commits show up here. Check Shipped in the composer when you ship something.' + : `@${user.handle} hasn't marked a commit as shipped yet.`} + ) : isCurrentUser ? ( Your commits show up here. Make your first diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 4e6b99583..cfe19a7d3 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -97,6 +97,7 @@ export type Commit = { link_preview?: LinkPreview | null; poll?: Poll | null; quoted_commit_id: string | null; + shipped?: boolean; quoted_commit?: QuotedCommit | DeletedCommit | null; images?: CommitImage[]; ai_decision?: Exclude | null; diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1d9199f10..5f2ce0c6b 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -18,6 +18,30 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +it('includes shipped only when a commit was marked shipped', function (): void { + $shipped = Commit::factory()->create(['shipped' => true]); + $plain = Commit::factory()->create(); + + $this->getJson(route('api-commit.show', $shipped)) + ->assertOk() + ->assertJsonPath('data.shipped', true); + + $this->getJson(route('api-commit.show', $plain)) + ->assertOk() + ->assertJsonMissingPath('data.shipped'); +}); + +it('does not let a signed request mark a commit shipped', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'Shipped from the app.', 'shipped' => true]) + ->assertCreated() + ->assertJsonMissingPath('data.shipped'); + + expect($user->commits()->sole()->shipped)->toBeFalse(); +}); + it('shows an approved commit to anyone', function (): void { $user = User::factory()->create([ 'handle' => 'octocat', diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 5809dd842..4f39e3cad 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -290,6 +290,72 @@ expect(Commit::query()->count())->toBe(0); }); +it('does not let a guest mark a commit shipped', function (): void { + $this->post(route('commit.store'), [ + 'body' => 'Shipped the parser.', + 'shipped' => true, + ])->assertRedirectToRoute('home'); + + expect(Commit::query()->count())->toBe(0); +}); + +it('may mark a top-level commit as shipped', function (mixed $shipped): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), ['body' => 'Shipped the parser.', 'shipped' => $shipped]) + ->assertRedirectToRoute('log.show', Log::Following) + ->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->shipped)->toBeTrue(); +})->with([ + 'boolean' => true, + 'form value' => '1', +]); + +it('leaves a commit unshipped when shipped is omitted or false', function (array $extra): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), ['body' => 'Still working.', ...$extra]) + ->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->shipped)->toBeFalse(); +})->with([ + 'omitted' => [[]], + 'false' => [['shipped' => false]], + 'zero' => [['shipped' => '0']], +]); + +it('rejects a shipped value that is not true or false', function (): void { + $this->actingAs(User::factory()->create()) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), ['body' => 'Hello', 'shipped' => 'maybe']) + ->assertSessionHasErrors(['shipped' => 'The shipped field must be true or false.']); + + expect(Commit::query()->count())->toBe(0); +}); + +it('rejects shipped on a reply or a quote', function (string $kind): void { + $commit = Commit::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), [ + 'body' => 'Not an announcement.', + 'shipped' => true, + ...($kind === 'reply' ? ['parent_id' => $commit->id] : ['quoted_commit_id' => $commit->id]), + ]) + ->assertSessionHasErrors(['shipped' => 'The shipped field prohibits parent id / quoted commit id from being present.']); + + expect(Commit::query()->count())->toBe(1); +})->with([ + 'reply' => ['reply'], + 'quote' => ['quote'], +]); + it('may delete own commit', function (): void { $commit = Commit::factory()->create(); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 476c098fa..0e4cc4de7 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -785,6 +785,17 @@ ->assertJsonPath('data.0.id', $reply->id); }); +it('may not mark a reply as shipped', function (): void { + $parent = Commit::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->postJson(route('commit-reply.store', $parent), ['body' => 'Nice work', 'shipped' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['shipped' => 'The shipped field is prohibited.']); + + expect($parent->replies()->count())->toBe(0); +}); + it('keeps quotes out of the json reply endpoint', function (): void { $user = User::factory()->create(); $parent = Commit::factory()->create(); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 23b4ff740..bbfc5580f 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -236,6 +236,7 @@ ], 'status' => 'approved', 'pasteis_count' => 3, + 'shipped' => false, 'images' => [], 'created_at' => $approved->created_at->toIso8601String(), 'updated_at' => $approved->updated_at->toIso8601String(), diff --git a/tests/Feature/Controllers/UserShippedCommitsTest.php b/tests/Feature/Controllers/UserShippedCommitsTest.php new file mode 100644 index 000000000..f0fad8222 --- /dev/null +++ b/tests/Feature/Controllers/UserShippedCommitsTest.php @@ -0,0 +1,102 @@ +create(); + $shipped = Commit::factory()->for($user)->create([ + 'shipped' => true, + 'body' => 'Shipped the parser', + 'created_at' => now()->subMinute(), + ]); + $pending = Commit::factory()->for($user)->pending()->create([ + 'shipped' => true, + 'body' => 'Pending shipped secret', + ]); + $pinned = Commit::factory()->for($user)->create([ + 'shipped' => true, + 'body' => 'Pinned and shipped', + 'created_at' => now()->subMinutes(2), + ]); + $user->update(['pinned_commit_id' => $pinned->id]); + $rejected = Commit::factory()->for($user)->rejected()->create([ + 'shipped' => true, + 'body' => 'Rejected shipped secret', + 'created_at' => now()->subMinutes(3), + ]); + Commit::factory()->for($user)->create(['body' => 'Not a shipping announcement']); + Commit::factory()->for($user)->for($shipped, 'parent')->create(['shipped' => true, 'body' => 'A shipped reply']); + $other = Commit::factory()->create(['shipped' => true, 'body' => 'Someone else shipped this']); + + $this->get(route('user.show', ['user' => $user, 'tab' => 'shipped'])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('tab', 'shipped') + ->has('commits.data', 2) + ->where('commits.data.0.id', $shipped->id) + ->where('commits.data.0.shipped', true) + ->where('commits.data.1.id', $pinned->id)) + ->assertDontSee('Pending shipped secret', false) + ->assertDontSee('Rejected shipped secret', false) + ->assertDontSee('Someone else shipped this', false) + ->assertDontSee('Not a shipping announcement', false); + + $this->actingAs(User::factory()->create()) + ->get(route('user.show', ['user' => $user, 'tab' => 'shipped'])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('commits.data', 2) + ->where('commits.data.0.id', $shipped->id) + ->where('commits.data.1.id', $pinned->id)) + ->assertDontSee($other->body, false); + + $this->actingAs($user) + ->get(route('user.show', ['user' => $user, 'tab' => 'shipped'])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/show') + ->where('tab', 'shipped') + ->where('pinnedCommit', null) + ->has('commits.data', 4) + ->where('commits.data.0.id', $pending->id) + ->where('commits.data.0.shipped', true) + ->where('commits.data.1.id', $shipped->id) + ->where('commits.data.2.id', $pinned->id) + ->where('commits.data.3.id', $rejected->id)); + + $this->get(route('commit.show', [$user, $shipped])) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('commit.shipped', true)); +}); + +it('keeps shipped commits on the commits tab and leaves a pinned one above the list', function (): void { + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(['shipped' => true, 'created_at' => now()->subMinutes(2)]); + $user->update(['pinned_commit_id' => $pinned->id]); + $other = Commit::factory()->for($user)->create(['shipped' => true, 'created_at' => now()->subMinute()]); + $plain = Commit::factory()->for($user)->create(['created_at' => now()]); + + $this->actingAs($user) + ->get(route('user.show', $user)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('tab', 'commits') + ->where('pinnedCommit.id', $pinned->id) + ->where('pinnedCommit.shipped', true) + ->has('commits.data', 2) + ->where('commits.data.0.id', $plain->id) + ->missing('commits.data.0.shipped') + ->where('commits.data.1.id', $other->id) + ->where('commits.data.1.shipped', true)); +}); + +it('rejects an unknown profile tab', function (): void { + $user = User::factory()->create(); + + $this->getJson(route('user.show', ['user' => $user, 'tab' => 'stars'])) + ->assertUnprocessable() + ->assertJsonValidationErrors('tab'); +}); diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index 7ed3b12bf..cf56e2fd0 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -31,9 +31,30 @@ expect($commit->exists)->toBeTrue() ->and($commit->body)->toBe('Hello, devnull!') - ->and($commit->user->is($user))->toBeTrue(); + ->and($commit->user->is($user))->toBeTrue() + ->and($commit->shipped)->toBeFalse(); }); +it('stores shipped only on a top-level commit', function (bool $shipped): void { + $commit = resolve(CreateCommit::class)->handle(User::factory()->create(), 'Shipped the parser', shipped: $shipped); + + expect($commit->shipped)->toBe($shipped); +})->with([true, false]); + +it('does not mark a reply or a quote as shipped', function (string $kind): void { + $other = Commit::factory()->create(); + + $commit = resolve(CreateCommit::class)->handle( + User::factory()->create(), + 'Not an announcement', + parent: $kind === 'reply' ? $other : null, + quotedCommit: $kind === 'quote' ? $other : null, + shipped: true, + ); + + expect($commit->shipped)->toBeFalse(); +})->with(['reply', 'quote']); + it('creates commits pending review', function (): void { $action = resolve(CreateCommit::class); diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 218a02af3..ddb51d267 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -190,6 +190,15 @@ for (const count of [1, 2, 3, 3850]) { }); } +test('offers to mark a commit as something you shipped', () => { + const markup = renderComposer('Shipped the parser'); + + assert.ok(markup.includes('data-test="shipped-checkbox"')); + assert.ok(markup.includes('for="body-shipped"')); + assert.ok(markup.includes('This commit is something you shipped.')); + assert.equal(markup.includes('name="shipped"'), false); +}); + test('links the composer avatar to the author profile', () => { const markup = renderComposer(''); const avatar = markup.match(/]*href="\/@previewuser"[^>]*>/)?.[0]; diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index e547239e6..c23773c08 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -35,6 +35,14 @@ ]); }); +it('includes shipped only when the commit was marked shipped', function (): void { + $shipped = Commit::factory()->create(['shipped' => true]); + $plain = Commit::factory()->create(); + + expect($shipped->toArray())->toHaveKey('shipped', true) + ->and($plain->toArray())->not->toHaveKey('shipped'); +}); + it('splits its body into segments', function (): void { $commit = Commit::factory()->create(['body' => 'run `make`']); diff --git a/tests/Unit/Queries/UserCommitsQueryTest.php b/tests/Unit/Queries/UserCommitsQueryTest.php index 49efe5880..58c8881ec 100644 --- a/tests/Unit/Queries/UserCommitsQueryTest.php +++ b/tests/Unit/Queries/UserCommitsQueryTest.php @@ -34,6 +34,31 @@ ->and(resolve(UserCommitsQuery::class)->builder($user, null)->get()->modelKeys())->toBe([$approved->id]); }); +it('lists only the visible shipped commits, including a pinned one', function (): void { + $user = User::factory()->create(); + $viewer = User::factory()->create(); + $shipped = Commit::factory()->for($user)->create(['shipped' => true, 'created_at' => now()->subMinute()]); + $pending = Commit::factory()->for($user)->pending()->create(['shipped' => true, 'created_at' => now()]); + $pinned = Commit::factory()->for($user)->create(['shipped' => true, 'created_at' => now()->subMinutes(2)]); + $user->update(['pinned_commit_id' => $pinned->id]); + $rejected = Commit::factory()->for($user)->rejected()->create(['shipped' => true, 'created_at' => now()->subMinutes(3)]); + Commit::factory()->for($user)->create(); + Commit::factory()->for($user)->for($shipped, 'parent')->create(['shipped' => true]); + Commit::factory()->create(['shipped' => true]); + + $query = resolve(UserCommitsQuery::class); + + expect($query->builder($user, $viewer, shipped: true)->get()->modelKeys())->toBe([$shipped->id, $pinned->id]) + ->and($query->builder($user, null, shipped: true)->get()->modelKeys())->toBe([$shipped->id, $pinned->id]) + ->and($query->builder($user, $user, shipped: true)->get()->modelKeys())->toBe([ + $pending->id, + $shipped->id, + $pinned->id, + $rejected->id, + ]) + ->and($query->builder($user, $user)->get()->modelKeys())->not->toContain($pinned->id); +}); + it('leaves the pinned commit out of the commits tab', function (): void { $user = User::factory()->create(); $pinned = Commit::factory()->for($user)->create(); From f206bf0e5c7ddedc4d9fd9953116cadfcc0c3a98 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Wed, 28 Oct 2026 09:04:14 +0000 Subject: [PATCH 2/2] fix: put restored pins back on the Shipped tab The Shipped tab lists a pinned commit in the feed, so undo has to put it back there. --- resources/js/components/commit-feed.tsx | 30 +++-- resources/js/lib/commit-mutation-options.ts | 28 +++++ tests/Unit/CommitMutationOptions.test.ts | 123 ++++++++++++++++++++ 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 4c87c07e4..da7faf11d 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -8,6 +8,7 @@ import { GraphRail, RailSegment } from '@/components/graph-rail'; import { useNow } from '@/hooks/use-now'; import { restoreFeedCommit, + restoredCommitBelongsInFeed, updateQuotedCommits, } from '@/lib/commit-mutation-options'; import { isDeliberateHover } from '@/lib/feed-hover'; @@ -54,6 +55,8 @@ export function CommitFeed({ : null; const isProfileReplies = profileUserId !== null && page.props.tab === 'replies'; + const isProfileShipped = + profileUserId !== null && page.props.tab === 'shipped'; const [highlightedAuthor, setHighlightedAuthor] = useState( null, ); @@ -141,15 +144,15 @@ export function CommitFeed({ ); } if (restored) { - const belongsInFeed = - (isStarred - ? restored.starred === true - : isProfileReplies - ? restored.parent_id !== null - : restored.parent_id === null) && - (profileUserId === null || - (restored.user_id === profileUserId && - !restored.is_pinned)); + const belongsInFeed = restoredCommitBelongsInFeed( + restored, + { + isStarred, + isProfileReplies, + isProfileShipped, + profileUserId, + }, + ); router.replaceProp('commits.data', (rows: Commit[]) => belongsInFeed @@ -166,7 +169,14 @@ export function CommitFeed({ ); } }), - [profileUserId, isModeration, isProfileReplies, isStarred, isRanked], + [ + profileUserId, + isModeration, + isProfileReplies, + isProfileShipped, + isStarred, + isRanked, + ], ); // A ranked commit can move to a later page while the feed scrolls, so each one is only listed once. diff --git a/resources/js/lib/commit-mutation-options.ts b/resources/js/lib/commit-mutation-options.ts index 1861cbec8..bb807dea1 100644 --- a/resources/js/lib/commit-mutation-options.ts +++ b/resources/js/lib/commit-mutation-options.ts @@ -33,6 +33,34 @@ export function updateQuotedCommits( ); } +// The Commits tab shows a pin in its own card, so a restored pin stays out of that list. Shipped has no +// pin card, and only a commit marked shipped belongs there. +export function restoredCommitBelongsInFeed( + restored: Commit, + feed: { + isStarred: boolean; + isProfileReplies: boolean; + isProfileShipped: boolean; + profileUserId: string | null; + }, +): boolean { + const matchesKind = feed.isStarred + ? restored.starred === true + : feed.isProfileReplies + ? restored.parent_id !== null + : restored.parent_id === null; + const matchesProfile = + feed.profileUserId === null || + (restored.user_id === feed.profileUserId && + (feed.isProfileShipped || !restored.is_pinned)); + + return ( + matchesKind && + matchesProfile && + (!feed.isProfileShipped || restored.shipped === true) + ); +} + export function restoreFeedCommit( rows: Commit[], restored: Commit, diff --git a/tests/Unit/CommitMutationOptions.test.ts b/tests/Unit/CommitMutationOptions.test.ts index 4bd446580..dced88fe9 100644 --- a/tests/Unit/CommitMutationOptions.test.ts +++ b/tests/Unit/CommitMutationOptions.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { restoreFeedCommit, + restoredCommitBelongsInFeed, updateQuotedCommits, } from '../../resources/js/lib/commit-mutation-options.ts'; import type { Commit, DeletedCommit } from '../../resources/js/types'; @@ -105,6 +106,128 @@ void test('restores a latest row by creation time and a starred row by star time ); }); +const restoredCommit = (overrides: Partial = {}) => + ({ + id: 'restored', + user_id: 'author', + parent_id: null, + starred: false, + is_pinned: false, + ...overrides, + }) as Commit; + +const commitsTab = { + isStarred: false, + isProfileReplies: false, + isProfileShipped: false, + profileUserId: 'author', +}; + +void test('puts a restored commit back only on the feed it belongs to', () => { + assert.equal( + restoredCommitBelongsInFeed(restoredCommit(), commitsTab), + true, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ is_pinned: true }), + commitsTab, + ), + false, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ parent_id: 'parent' }), + commitsTab, + ), + false, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ user_id: 'someone' }), + commitsTab, + ), + false, + ); + + const repliesTab = { ...commitsTab, isProfileReplies: true }; + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ parent_id: 'parent' }), + repliesTab, + ), + true, + ); + assert.equal( + restoredCommitBelongsInFeed(restoredCommit(), repliesTab), + false, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ parent_id: 'parent', is_pinned: true }), + repliesTab, + ), + false, + ); + + const shippedTab = { ...commitsTab, isProfileShipped: true }; + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ shipped: true }), + shippedTab, + ), + true, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ shipped: true, is_pinned: true }), + shippedTab, + ), + true, + ); + assert.equal( + restoredCommitBelongsInFeed(restoredCommit(), shippedTab), + false, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ shipped: true, parent_id: 'parent' }), + shippedTab, + ), + false, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ shipped: true, user_id: 'someone' }), + shippedTab, + ), + false, + ); + + const log = { ...commitsTab, profileUserId: null }; + assert.equal( + restoredCommitBelongsInFeed(restoredCommit({ is_pinned: true }), log), + true, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ parent_id: 'parent' }), + log, + ), + false, + ); + + const starred = { ...log, isStarred: true }; + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ starred: true, parent_id: 'parent' }), + starred, + ), + true, + ); + assert.equal(restoredCommitBelongsInFeed(restoredCommit(), starred), false); +}); + void test('restoring a target also refreshes its embeds while preserving the loaded rows', () => { const restored = { ...target, hot_score: 5, created_at: '2026-10-16' }; const rows = restoreFeedCommit(quotes, restored, 'ranked');