diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d7bb9bb48..501a5e631 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -100,7 +100,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. Like on GitHub, the handle's case doesn't matter: `/@octocat` opens the profile of `@OctoCat`, and the address switches to the handle's own case. The same goes for organization pages and commit links. 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, for members only: a guest goes to GitHub sign-in instead, which keeps scrapers from walking the follow graph page by page. 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. Like on GitHub, the handle's case doesn't matter: `/@octocat` opens the profile of `@OctoCat`, and the address switches to the handle's own case. The same goes for organization pages and commit links. 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, for members only: a guest goes to GitHub sign-in instead, which keeps scrapers from walking the follow graph page by page. 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. @@ -202,11 +202,13 @@ 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**. When a commit starts with a code block too long to fit, its first lines fade out to show that the code continues. **Show more** reveals the full snippet, and the copy control copies the whole block even while folded. 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. 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). +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. @@ -272,7 +274,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. @@ -292,7 +294,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 9bc21f6ba..de75cc176 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -54,8 +54,9 @@ public function __construct( * @param list $images * @param array $imageDescriptions * @param array $replyRecipients + * @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, 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, bool $shipped = false): 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, $shipped): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -79,6 +80,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 289e5abb1..a1869fb01 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -189,7 +189,7 @@ private function commits(User $user): Generator { $commits = $user->commits() ->withTrashed() - ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) + ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'shipped', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -207,6 +207,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 ecfe35a1e..1fdc2f2a7 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -68,7 +68,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, 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 f54a2dd86..9391f1d76 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -77,7 +77,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 aea37cdf3..aa7e42c9f 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -59,6 +59,7 @@ public function rules(): array 'reply_recipients' => ['sometimes', 'list', 'max:'.self::MAX_REPLY_RECIPIENTS], 'reply_recipients.*' => ['required', 'string', 'distinct', Rule::in(array_values($parent->mentions ?? []))], '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 26264c458..61c80df1d 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -92,6 +92,7 @@ public function rules(): array 'body' => self::bodyRules($this->hasFile('images')), 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], '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, ]; @@ -129,6 +130,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 31dc2a058..a4322543c 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' => ProfileResource::make($this->author), + 'shipped' => $this->when($this->shipped, true), ]; if (! $isSigned) { diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de..bc1bf4bd6 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -45,6 +45,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 Profile|null $author @@ -103,6 +104,7 @@ final class Commit extends Model */ protected $attributes = [ 'pasteis_count' => 0, + 'shipped' => false, ]; /** @@ -123,6 +125,7 @@ public function casts(): array 'root_id' => 'string', 'hot_score' => 'float', 'pasteis_count' => 'integer', + 'shipped' => 'boolean', 'body' => 'string', 'mentions' => 'array', 'organization_mentions' => 'array', @@ -158,7 +161,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 9ce30e753..3996cb750 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -30,7 +30,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', 'author_id', + 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', 'shipped', 'author_id', 'headlines_classified_at', ]; diff --git a/app/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php index a097dc0fe..8784853a9 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() ->where('author_id', $user->id) @@ -28,7 +28,11 @@ public function builder(User $user, ?User $viewer, bool $replies = false): Commi ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) ->with('author')]), 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 000353a52..25e59f0ea 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -405,6 +405,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(() => { @@ -447,6 +450,33 @@ function CommitComposerBody({ )} + {!isIssue && !isScheduling && ( +
+ + setShipped(checked === true) + } + className="mt-0.5" + data-test="shipped-checkbox" + /> + {shipped && ( + + )} + +
+ )} +
{!isIssue && ( diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 5d655f847..506d64ff5 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'; // The organization's Mentions tab lists replies too, whatever handle they mention it with. const mentionedOrganizationId = page.component === 'organization/show' @@ -146,19 +149,16 @@ export function CommitFeed({ ); } if (restored) { - const belongsInFeed = - (isStarred - ? restored.starred === true - : mentionedOrganizationId !== null - ? Object.values( - restored.organization_mentions ?? {}, - ).includes(mentionedOrganizationId) - : isProfileReplies - ? restored.parent_id !== null - : restored.parent_id === null) && - (profileUserId === null || - (restored.author.id === profileUserId && - !restored.is_pinned)); + const belongsInFeed = restoredCommitBelongsInFeed( + restored, + { + isStarred, + isProfileReplies, + isProfileShipped, + profileUserId, + mentionedOrganizationId, + }, + ); router.replaceProp('commits.data', (rows: Commit[]) => belongsInFeed @@ -179,6 +179,7 @@ export function CommitFeed({ profileUserId, isModeration, isProfileReplies, + isProfileShipped, mentionedOrganizationId, isStarred, isRanked, diff --git a/resources/js/lib/commit-mutation-options.ts b/resources/js/lib/commit-mutation-options.ts index 1861cbec8..b6020e9bc 100644 --- a/resources/js/lib/commit-mutation-options.ts +++ b/resources/js/lib/commit-mutation-options.ts @@ -33,6 +33,40 @@ 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. An organization's Mentions tab lists replies +// too, whatever handle they mention it with. +export function restoredCommitBelongsInFeed( + restored: Commit, + feed: { + isStarred: boolean; + isProfileReplies: boolean; + isProfileShipped: boolean; + profileUserId: string | null; + mentionedOrganizationId: string | null; + }, +): boolean { + const matchesKind = feed.isStarred + ? restored.starred === true + : feed.mentionedOrganizationId !== null + ? Object.values(restored.organization_mentions ?? {}).includes( + feed.mentionedOrganizationId, + ) + : feed.isProfileReplies + ? restored.parent_id !== null + : restored.parent_id === null; + const matchesProfile = + feed.profileUserId === null || + (restored.author.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/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 6e8f7c11c..5f6ea5f7a 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -93,7 +93,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; }) { @@ -567,6 +567,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' } }), @@ -609,9 +614,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' ? ( @@ -620,6 +629,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 73c8df690..e9d942411 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -115,6 +115,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 79bb90d31..4efabc180 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -20,6 +20,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('bounds combined inherited and explicit reply recipients', function (int $inheritedCount, bool $accepted): void { Queue::fake(); $author = User::factory()->create(); diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 8edc654c7..7fed9b12a 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -291,6 +291,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 c69d00932..3ceb5c8a5 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -849,6 +849,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 60160eaca..a78f3055b 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -250,6 +250,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 b4b3acf7b..19962244b 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -32,9 +32,30 @@ expect($commit->exists)->toBeTrue() ->and($commit->body)->toBe('Hello, devnull!') ->and($commit->user->is($user))->toBeTrue() + ->and($commit->shipped)->toBeFalse() ->and($commit->author_id)->toBe($user->id); }); +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/CommitMutationOptions.test.ts b/tests/Unit/CommitMutationOptions.test.ts index 4bd446580..ac8e36df4 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,156 @@ void test('restores a latest row by creation time and a starred row by star time ); }); +const restoredCommit = (overrides: Partial = {}) => + ({ + id: 'restored', + author: { id: 'author', type: 'user' } as Commit['author'], + parent_id: null, + starred: false, + is_pinned: false, + ...overrides, + }) as Commit; + +const commitsTab = { + isStarred: false, + isProfileReplies: false, + isProfileShipped: false, + profileUserId: 'author', + mentionedOrganizationId: null, +}; + +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({ + author: { id: 'someone', type: 'user' } as Commit['author'], + }), + 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, + author: { id: 'someone', type: 'user' } as Commit['author'], + }), + 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); + + const mentions = { + ...log, + mentionedOrganizationId: 'org', + }; + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ + parent_id: 'parent', + organization_mentions: { laravel: 'org' }, + }), + mentions, + ), + true, + ); + assert.equal( + restoredCommitBelongsInFeed( + restoredCommit({ organization_mentions: { laravel: 'other' } }), + mentions, + ), + 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'); diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 36128ef8b..547b67797 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 17baaae31..b5b7c2163 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -36,6 +36,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('never shows who posted it, even loaded', function (): void { expect(Commit::factory()->create()->load('user')->toArray())->not->toHaveKeys(['user', 'user_id']); }); diff --git a/tests/Unit/Queries/UserCommitsQueryTest.php b/tests/Unit/Queries/UserCommitsQueryTest.php index dde131228..796537e71 100644 --- a/tests/Unit/Queries/UserCommitsQueryTest.php +++ b/tests/Unit/Queries/UserCommitsQueryTest.php @@ -43,6 +43,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();