From ace18518a900049d0b41353d728b3cfc9ce43ffc Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 14:25:23 +0100 Subject: [PATCH 1/9] feat: link @organization mentions to their page --- KNOWLEDGE_BASE.md | 11 +- PRODUCT.md | 3 +- app/Actions/CreateCommit.php | 10 +- app/Actions/CreateCommitTopics.php | 28 +++ app/Actions/DeleteCommitContent.php | 2 + app/Actions/DeleteOrganization.php | 28 +++ app/Actions/DeleteUser.php | 1 + app/Actions/UpdateCommitStatuses.php | 2 + app/Actions/UpdateCommitTopics.php | 107 +++++++++ app/Actions/UpdateOrCreateOrganization.php | 42 ++++ app/Enums/ProfileType.php | 1 + app/Enums/TopicType.php | 14 ++ .../Api/CommitPreviewController.php | 6 +- .../Controllers/CommitPreviewController.php | 6 +- .../MentionSuggestionController.php | 8 +- .../Controllers/OrganizationController.php | 22 ++ .../OrganizationMentionController.php | 19 ++ app/Http/Requests/ShowOrganizationRequest.php | 42 ++++ app/Http/Resources/CommitResource.php | 2 +- app/Jobs/LinkCommitTopics.php | 54 +++++ app/Models/Commit.php | 13 ++ app/Models/Profile.php | 5 + app/Queries/CommitBuilder.php | 11 +- app/Queries/MentionedOrganizationsQuery.php | 42 ++++ app/Queries/OrganizationSuggestionsQuery.php | 49 ++++ app/Services/Mentions.php | 9 +- database/factories/ProfileFactory.php | 10 + ...0_26_200000_create_commit_topics_table.php | 29 +++ resources/js/components/commit-body.tsx | 11 + resources/js/components/commit-card.tsx | 3 + resources/js/components/commit-thread.tsx | 1 + resources/js/components/composer-preview.tsx | 20 +- resources/js/components/linkified-text.tsx | 25 ++- resources/js/components/mention-textarea.tsx | 109 +++++++-- .../js/components/organization-avatar.tsx | 44 ++++ resources/js/pages/log/show.tsx | 3 + resources/js/pages/organization/show.tsx | 106 +++++++++ resources/js/types/commit.ts | 1 + resources/js/types/index.ts | 1 + resources/js/types/organization.ts | 8 + routes/web.php | 24 +- .../Controllers/Api/CommitControllerTest.php | 2 +- .../Api/ConversationMessageControllerTest.php | 2 +- .../Controllers/Api/LogControllerTest.php | 2 +- .../CommitPreviewControllerTest.php | 2 +- .../MentionSuggestionControllerTest.php | 30 +++ .../OrganizationControllerTest.php | 44 ++++ tests/Feature/OrganizationMentionsTest.php | 107 +++++++++ tests/Unit/Actions/CreateCommitTopicsTest.php | 28 +++ .../Unit/Actions/DeleteCommitContentTest.php | 7 +- tests/Unit/Actions/DeleteUserTest.php | 6 +- tests/Unit/Actions/UpdateCommitTopicsTest.php | 210 ++++++++++++++++++ .../UpdateOrCreateOrganizationTest.php | 39 ++++ tests/Unit/Jobs/LinkCommitTopicsTest.php | 19 ++ tests/Unit/LinkifiedText.test.tsx | 38 ++++ tests/Unit/Models/CommitTest.php | 1 + tests/Unit/Queries/CommitBuilderTest.php | 4 +- tests/Unit/Services/MentionsTest.php | 14 +- 58 files changed, 1436 insertions(+), 51 deletions(-) create mode 100644 app/Actions/CreateCommitTopics.php create mode 100644 app/Actions/DeleteOrganization.php create mode 100644 app/Actions/UpdateCommitTopics.php create mode 100644 app/Actions/UpdateOrCreateOrganization.php create mode 100644 app/Enums/TopicType.php create mode 100644 app/Http/Controllers/OrganizationController.php create mode 100644 app/Http/Controllers/OrganizationMentionController.php create mode 100644 app/Http/Requests/ShowOrganizationRequest.php create mode 100644 app/Jobs/LinkCommitTopics.php create mode 100644 app/Queries/MentionedOrganizationsQuery.php create mode 100644 app/Queries/OrganizationSuggestionsQuery.php create mode 100644 database/migrations/2026_10_26_200000_create_commit_topics_table.php create mode 100644 resources/js/components/organization-avatar.tsx create mode 100644 resources/js/pages/organization/show.tsx create mode 100644 resources/js/types/organization.ts create mode 100644 tests/Feature/Controllers/OrganizationControllerTest.php create mode 100644 tests/Feature/OrganizationMentionsTest.php create mode 100644 tests/Unit/Actions/CreateCommitTopicsTest.php create mode 100644 tests/Unit/Actions/UpdateCommitTopicsTest.php create mode 100644 tests/Unit/Actions/UpdateOrCreateOrganizationTest.php create mode 100644 tests/Unit/Jobs/LinkCommitTopicsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f08a1cf81..2240656c7 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -11,6 +11,7 @@ This file documents the platform's features, stack, and stories. Feature entries - [Public browsing and GitHub sign-in](#public-browsing-and-github-sign-in) - [First-time setup](#first-time-setup) - [Profiles and GitHub sync](#profiles-and-github-sync) + - [Organization pages](#organization-pages) - [Going live](#going-live) - [Achievement badges and XP](#achievement-badges-and-xp) - [Following developers and suggestions](#following-developers-and-suggestions) @@ -106,6 +107,14 @@ Profiles also show [achievement badges and an XP-based Unix rank](#achievement-b Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php). +### Organization pages + +GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization or edits its page: everything on it comes from GitHub. + +Following organizations and reading what the community commits about them are planned, not shipped yet. + +Sources: [organization page](resources/js/pages/organization/show.tsx), [organization lookups](app/Actions/UpdateCommitTopics.php). + ### Going live Share a stream you are running from the **…** menu on your own profile: **Go live** takes a link to a YouTube or Twitch stream (on YouTube: a `/live/` link, a watch link, an embed, or one with a start time all work; on Twitch: your channel link) and how long to show it, from **1 to 12 hours** (two by default). YouTube and Twitch are supported. While you are live, your avatar gets a red ring and a **Live** marker on your profile, in logs, and in hover cards. Hovering it (or tapping it on a touch screen) shows the stream's live thumbnail with a **Watch** button; the stream itself plays on the provider in a new tab, never embedded. On your profile, the avatar links straight to the stream. @@ -206,7 +215,7 @@ Commit and reply text supports inline code in backticks and fenced code blocks w In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal. -Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. +Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies nobody: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded. diff --git a/PRODUCT.md b/PRODUCT.md index dd878eac7..84fded799 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -35,6 +35,7 @@ What exists, one line each (details in the knowledge base): - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. +- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. - **Remotes:** private one-to-one and group messages (orgs). - **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open. - **Search:** developers by name, commits by meaning. @@ -59,7 +60,7 @@ Deliberately excluded: sign-up or identity outside GitHub, editing commits, and - Voice: dry, terminal-literate humor. Understated wit; copy may wink at Unix and `/dev/null`, but stays clear and never obscures what an action does. - Lore is part of the product: Tom (everyone's first friend, after MySpace Tom), pastéis (a tribute to the pastéis de nata brought to the livestream), @world, the Unix-named achievements. Extend it with restraint, never at the expense of clarity. The stories behind it are in the knowledge base. - Current tagline in use: "A social network for developers." -- Vocabulary: a post is a **commit** and a timeline is a **log**, in the UI and in code (`Commit` model, `LogController`, `/log/{log}`). A GitHub account mirrored at its `/@handle` is a **profile** (`Profile` model, as GitHub's `ProfileOwner`), and the unique name GitHub calls its login is its **handle** in code and in the API. The git metaphor stops at those two nouns, except inside Remotes, which carries its own small vocabulary: a conversation is a **remote**, sending is a **push**, a read receipt is **pulled**, a request is **fetched** until **merged** or **dropped**, archiving is **stash** and **pop**, a group is an **org** of **maintainers** and **contributors**, and **HEAD** marks where you left off. No branches, forks, diffs, or PRs. **Star** is the engagement verb, not a git ref. +- Vocabulary: a post is a **commit** and a timeline is a **log**, in the UI and in code (`Commit` model, `LogController`, `/log/{log}`). A GitHub account mirrored at its `/@handle` is a **profile** (`Profile` model, as GitHub's `ProfileOwner`), a developer's or a GitHub **organization**'s, and the unique name GitHub calls its login is its **handle** in code and in the API. A GitHub organization is never called an org, so it is not mistaken for a Remotes group. The git metaphor stops at those two nouns, except inside Remotes, which carries its own small vocabulary: a conversation is a **remote**, sending is a **push**, a read receipt is **pulled**, a request is **fetched** until **merged** or **dropped**, archiving is **stash** and **pop**, a group is an **org** of **maintainers** and **contributors**, and **HEAD** marks where you left off. No branches, forks, diffs, or PRs. **Star** is the engagement verb, not a git ref. ## Evidence on Hand diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..ee292d898 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -9,12 +9,14 @@ use App\Enums\CommitStatus; use App\Jobs\CheckUserLevel; use App\Jobs\EmbedCommit; +use App\Jobs\LinkCommitTopics; use App\Jobs\ModerateCommit; use App\Jobs\ReplyAsTom; use App\Jobs\UnfurlLink; use App\Models\Commit; use App\Models\LinkPreview; use App\Models\User; +use App\Queries\MentionedOrganizationsQuery; use App\Queries\MentionedUsersQuery; use App\Queries\TomQuery; use App\Services\EmojiShortcodes; @@ -36,6 +38,8 @@ public function __construct( private EmojiShortcodes $emojiShortcodes, private Urls $urls, private MentionedUsersQuery $mentionedUsers, + private MentionedOrganizationsQuery $mentionedOrganizations, + private CreateCommitTopics $createCommitTopics, private TomQuery $tomQuery, ) { // @@ -59,10 +63,11 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); + $mentions = $this->mentionedUsers->get($body); $commit = $user->commits()->create([ 'body' => $this->emojiShortcodes->replace($body), - 'mentions' => $this->mentionedUsers->get($body), + 'mentions' => $mentions, 'parent_id' => $parent?->id, 'root_id' => $parent?->threadRootId(), 'status' => $user->isAdmin() || $hasGoodReputation ? CommitStatus::Approved : CommitStatus::Pending, @@ -72,6 +77,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'created_at' => $createdAt, ]); + $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($body, $mentions)); $this->createCommitImages->handle($commit, $images, $imageDescriptions); if ($poll instanceof NewPoll) { @@ -106,6 +112,8 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit dispatch(new CheckUserLevel($user))->afterCommit(); } + LinkCommitTopics::dispatchFor($commit); + if ($commit->status === CommitStatus::Pending && ! $hasGoodReputation) { dispatch(new ModerateCommit($commit))->afterCommit(); } diff --git a/app/Actions/CreateCommitTopics.php b/app/Actions/CreateCommitTopics.php new file mode 100644 index 000000000..59e4ae634 --- /dev/null +++ b/app/Actions/CreateCommitTopics.php @@ -0,0 +1,28 @@ + $organizations + */ + public function handle(Commit $commit, array $organizations): void + { + DB::table('commit_topics')->insertOrIgnore(array_map(fn (int|string $reference, string $id): array => [ + 'commit_id' => $commit->id, + 'reference' => (string) $reference, + 'topic_type' => TopicType::Profile->value, + 'topic_id' => $id, + ], array_keys($organizations), $organizations)); + } +} diff --git a/app/Actions/DeleteCommitContent.php b/app/Actions/DeleteCommitContent.php index 4bb934876..0a8760e9a 100644 --- a/app/Actions/DeleteCommitContent.php +++ b/app/Actions/DeleteCommitContent.php @@ -33,6 +33,8 @@ public function handle(Commit $commit): void LinkPreview::query()->where('url', 'like', "%/commits/{$commit->id}%")->delete(); + DB::table('commit_topics')->where('commit_id', $commit->id)->delete(); + $commit->update(['body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); return $imagePaths; diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php new file mode 100644 index 000000000..b73ff6592 --- /dev/null +++ b/app/Actions/DeleteOrganization.php @@ -0,0 +1,28 @@ +where('topic_type', TopicType::Profile->value) + ->where('topic_id', $organization->id) + ->delete(); + + $organization->delete(); + }); + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 479931c44..be9720678 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -59,6 +59,7 @@ public function handle(User $user): void } $user->commits()->delete(); + DB::table('commit_topics')->whereIn('commit_id', $user->commits()->withTrashed()->select('id'))->delete(); $user->commits()->withTrashed()->update(['user_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index b8b901c3e..3c653c040 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -7,6 +7,7 @@ use App\Enums\CommitStatus; use App\Enums\NotificationType; use App\Jobs\CheckUserLevel; +use App\Jobs\LinkCommitTopics; use App\Jobs\ReplyAsTom; use App\Models\Commit; use App\Models\Notification; @@ -84,6 +85,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = $this->createMentionNotifications->handle(...$commits); $commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); + LinkCommitTopics::dispatchFor(...$commits->all()); if (($tom = $this->tomQuery->get()) instanceof User) { foreach ($commits as $commit) { diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php new file mode 100644 index 000000000..38f4094ee --- /dev/null +++ b/app/Actions/UpdateCommitTopics.php @@ -0,0 +1,107 @@ +mentionedNonUsers(); + + if ($handles === []) { + return; + } + + $known = Profile::query() + ->where('type', ProfileType::Organization) + ->whereIn(DB::raw('LOWER(handle)'), $handles) + ->get(); + + /** @var list $lookups */ + $lookups = collect($handles) + ->diff($known->map(fn (Profile $organization): string => Str::lower($organization->handle))) + ->reject(fn (string $handle): bool => Cache::has("github-organizations:not-found:{$handle}")) + ->map(fn (string $handle): array => [ + "users/{$handle}", + fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)), + ]) + ->merge($known + ->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true) + ->map(fn (Profile $organization): array => [ + "user/{$organization->github_id}", + function () use ($organization): void { + $this->deleteOrganization->handle($organization); + }, + ])) + ->take(self::MaxLookups) + ->values() + ->all(); + + foreach ($lookups as [$path, $notFound]) { + if (Cache::has('github-organizations:paused')) { + break; + } + + $response = $this->github->request()->get($path); + $remaining = $response->header('X-RateLimit-Remaining'); + + if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) { + Cache::put('github-organizations:paused', true, CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset'))); + } + + if ($response->notFound() || $response->throw()->json('type') !== 'Organization') { + $notFound(); + + continue; + } + + /** @var array $account */ + $account = $response->json(); + + $this->updateOrCreateOrganization->handle($account); + } + + $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($commit->body, $commit->mentions ?? [])); + } +} diff --git a/app/Actions/UpdateOrCreateOrganization.php b/app/Actions/UpdateOrCreateOrganization.php new file mode 100644 index 000000000..1aaff83f9 --- /dev/null +++ b/app/Actions/UpdateOrCreateOrganization.php @@ -0,0 +1,42 @@ + $account + */ + public function handle(array $account): Profile + { + $profile = GitHubProfile::fromAccount($account); + $githubId = Arr::get($account, 'id'); + assert(is_int($githubId)); + + return DB::transaction(function () use ($profile, $githubId): Profile { + $this->updateStaleHandles->handle($profile->handle, (string) $githubId); + + return Profile::query()->updateOrCreate(['github_id' => (string) $githubId], [ + ...$profile->attributes(), + 'type' => ProfileType::Organization, + 'github_synced_at' => now(), + ]); + }); + } +} diff --git a/app/Enums/ProfileType.php b/app/Enums/ProfileType.php index 52a9fd0e7..504002bb0 100644 --- a/app/Enums/ProfileType.php +++ b/app/Enums/ProfileType.php @@ -10,4 +10,5 @@ enum ProfileType: string { case User = 'user'; + case Organization = 'organization'; } diff --git a/app/Enums/TopicType.php b/app/Enums/TopicType.php new file mode 100644 index 000000000..7f9b67e37 --- /dev/null +++ b/app/Enums/TopicType.php @@ -0,0 +1,14 @@ +replace($request->string('body')->value()); return response()->json(['data' => [ 'body' => $body, ...$commitBodies->segments($body), - 'mentions' => $mentions->positions($body), + 'mentions' => $mentions->positions($body, $mentionedOrganizations->get($body, $mentionedUsers->get($body))), ]]); } } diff --git a/app/Http/Controllers/CommitPreviewController.php b/app/Http/Controllers/CommitPreviewController.php index 0821474c5..816e0b2fd 100644 --- a/app/Http/Controllers/CommitPreviewController.php +++ b/app/Http/Controllers/CommitPreviewController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Http\Requests\CreateCommitPreviewRequest; +use App\Queries\MentionedOrganizationsQuery; use App\Queries\MentionedUsersQuery; use App\Services\CodeSpans; use App\Services\EmojiShortcodes; @@ -21,12 +22,15 @@ public function store( EmojiShortcodes $emojiShortcodes, CodeSpans $codeSpans, MentionedUsersQuery $mentionedUsers, + MentionedOrganizationsQuery $mentionedOrganizations, ): JsonResponse { $body = $request->string('body')->value(); + $mentions = $mentionedUsers->get($body); return response()->json([ 'segments' => $codeSpans->highlight($emojiShortcodes->replace($body)), - 'mentions' => $mentionedUsers->get($body), + 'mentions' => $mentions, + 'organization_mentions' => $mentionedOrganizations->get($body, $mentions), ]); } } diff --git a/app/Http/Controllers/MentionSuggestionController.php b/app/Http/Controllers/MentionSuggestionController.php index 90227bce8..253a8ab8e 100644 --- a/app/Http/Controllers/MentionSuggestionController.php +++ b/app/Http/Controllers/MentionSuggestionController.php @@ -5,17 +5,23 @@ namespace App\Http\Controllers; use App\Http\Requests\ListMentionSuggestionsRequest; +use App\Models\Conversation; use App\Models\User; use App\Queries\MentionSuggestionsQuery; +use App\Queries\OrganizationSuggestionsQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; final readonly class MentionSuggestionController { - public function index(#[CurrentUser] User $user, ListMentionSuggestionsRequest $request, MentionSuggestionsQuery $suggestions): JsonResponse + /** + * Organizations are only suggested outside orgs, since mentions only link to them in commits. + */ + public function index(#[CurrentUser] User $user, ListMentionSuggestionsRequest $request, MentionSuggestionsQuery $suggestions, OrganizationSuggestionsQuery $organizations): JsonResponse { return response()->json([ 'users' => $suggestions->get($user, $request->string('q')->value(), $request->org(), reply: $request->reply(), quote: $request->quote()), + 'organizations' => $request->org() instanceof Conversation ? [] : $organizations->get($request->string('q')->value()), ]); } } diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php new file mode 100644 index 000000000..e483f6f47 --- /dev/null +++ b/app/Http/Controllers/OrganizationController.php @@ -0,0 +1,22 @@ + $request->organization(), + ]); + } +} diff --git a/app/Http/Controllers/OrganizationMentionController.php b/app/Http/Controllers/OrganizationMentionController.php new file mode 100644 index 000000000..e78f5a311 --- /dev/null +++ b/app/Http/Controllers/OrganizationMentionController.php @@ -0,0 +1,19 @@ + $organization->handle]); + } +} diff --git a/app/Http/Requests/ShowOrganizationRequest.php b/app/Http/Requests/ShowOrganizationRequest.php new file mode 100644 index 000000000..6e78f2c99 --- /dev/null +++ b/app/Http/Requests/ShowOrganizationRequest.php @@ -0,0 +1,42 @@ +organization() instanceof Profile ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } + + /** + * Organizations share the "@handle" pages with users, so this is the handle no user has. + */ + public function organization(): ?Profile + { + $handle = $this->route('user'); + assert(is_string($handle)); + + return $this->organization ??= Profile::query() + ->where('type', ProfileType::Organization) + ->where('handle', $handle) + ->first(); + } +} diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 6ce3b53b2..f8340df0c 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -75,7 +75,7 @@ public function toArray(Request $request): array return [ ...$commit, ...resolve(CommitBodies::class)->segments($this->body), - 'mentions' => resolve(Mentions::class)->positions($this->body), + 'mentions' => resolve(Mentions::class)->positions($this->body, $this->organization_mentions ?? []), 'status' => $this->status, 'is_pinned' => $this->user?->pinned_commit_id === $this->id, 'starred' => $this->whenHas('starred'), diff --git a/app/Jobs/LinkCommitTopics.php b/app/Jobs/LinkCommitTopics.php new file mode 100644 index 000000000..5b343a9c1 --- /dev/null +++ b/app/Jobs/LinkCommitTopics.php @@ -0,0 +1,54 @@ + $commit->mentionedNonUsers() !== []); + + if ($mentioning === []) { + return; + } + + Commit::query() + ->whereKey(array_map(fn (Commit $commit): string => $commit->id, array_values($mentioning))) + ->where('status', CommitStatus::Approved) + ->get() + ->each(fn (Commit $commit) => dispatch(new self($commit))->afterCommit()); + } + + public function handle(UpdateCommitTopics $action): void + { + $action->handle($this->commit); + } +} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 5e0da8e6e..e91a35d4c 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -7,6 +7,7 @@ use App\Enums\CommitStatus; use App\Queries\CommitBuilder; use App\Services\CommitBodies; +use App\Services\Mentions; use Carbon\CarbonInterface; use Database\Factories\CommitFactory; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -30,6 +31,7 @@ * @property-read float $hot_score * @property-read string $body * @property-read array|null $mentions + * @property-read array|null $organization_mentions * @property-read CommitStatus $status * @property-read string|null $link_preview_id * @property-read string|null $quoted_commit_id @@ -114,6 +116,7 @@ public function casts(): array 'pasteis_count' => 'integer', 'body' => 'string', 'mentions' => 'array', + 'organization_mentions' => 'array', 'status' => CommitStatus::class, 'link_preview_id' => 'string', 'quoted_commit_id' => 'string', @@ -164,6 +167,16 @@ public function stars(): BelongsToMany return $this->belongsToMany(User::class, 'stars')->withTimestamps(); } + /** + * The handles it mentions that aren't a user's, which may be GitHub organizations'. + * + * @return list + */ + public function mentionedNonUsers(): array + { + return array_values(array_diff(resolve(Mentions::class)->handles($this->body), array_keys($this->mentions ?? []))); + } + public function isAddressedTo(User $user): bool { return in_array($user->id, $this->mentions ?? [], true) || $this->parent?->user_id === $user->id; diff --git a/app/Models/Profile.php b/app/Models/Profile.php index e663828a3..e1175b749 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -53,6 +53,11 @@ final class Profile extends Model */ public const array USER_ATTRIBUTES = ['handle', 'name', 'avatar_url', 'bio', 'location', 'website', 'github_synced_at']; + /** + * Organizations are refreshed from GitHub when a commit mentions them, at most this often. + */ + public const int FRESH_FOR_HOURS = 24; + /** * @return array */ diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 4243256ec..1c3ad8d03 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\Query\Expression as QueryExpression; use Illuminate\Pagination\Cursor; /** @@ -30,9 +31,15 @@ final class CommitBuilder extends Builder 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', ]; + /** + * The organizations a commit mentions, by lowercase handle, from its topics of type TopicType::Profile. + */ + private const string ORGANIZATION_MENTIONS = "(select json_object_agg(reference, topic_id) from commit_topics where commit_topics.commit_id = commits.id and topic_type = 'profile') as organization_mentions"; + /** * Embeddings are only ever compared within the database, so commits are loaded without theirs rather than carry - * kilobytes of numbers each that nothing reads. + * kilobytes of numbers each that nothing reads. They come with the organizations they mention instead, by lowercase + * handle, read from their topics in the same query, so rendering a page of commits takes no query more. * * @param array|string $columns * @return array @@ -43,7 +50,7 @@ public function getModels($columns = ['*']): array $this->query->columns = array_merge(...array_map( fn (Expression|string $column): array => $this->isEveryColumn($column) - ? array_map($this->qualifyColumn(...), self::COLUMNS_BUT_EMBEDDING) + ? [...array_map($this->qualifyColumn(...), self::COLUMNS_BUT_EMBEDDING), new QueryExpression(self::ORGANIZATION_MENTIONS)] : [$column], $selected ?? (array) $columns, )); diff --git a/app/Queries/MentionedOrganizationsQuery.php b/app/Queries/MentionedOrganizationsQuery.php new file mode 100644 index 000000000..4f72941b3 --- /dev/null +++ b/app/Queries/MentionedOrganizationsQuery.php @@ -0,0 +1,42 @@ + $mentionedUsers + * @return array + */ + public function get(string $body, array $mentionedUsers): array + { + $handles = array_values(array_diff($this->mentions->handles($body), array_keys($mentionedUsers))); + + if ($handles === []) { + return []; + } + + return Profile::query() + ->where('type', ProfileType::Organization) + ->whereIn(DB::raw('LOWER(handle)'), $handles) + ->get(['id', 'handle']) + ->mapWithKeys(fn (Profile $organization): array => [Str::lower($organization->handle) => $organization->id]) + ->all(); + } +} diff --git a/app/Queries/OrganizationSuggestionsQuery.php b/app/Queries/OrganizationSuggestionsQuery.php new file mode 100644 index 000000000..9ddca1111 --- /dev/null +++ b/app/Queries/OrganizationSuggestionsQuery.php @@ -0,0 +1,49 @@ + + */ + public function get(string $term, int $limit = 3): Collection + { + $term = Str::of($term) + ->trim() + ->ltrim('@') + ->lower() + ->value(); + + if ($term === '') { + return collect(); + } + + $escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $term); + + return Profile::query() + ->where('type', ProfileType::Organization) + ->where(fn (Builder $query): Builder => $query + ->whereRaw("LOWER(handle) LIKE ? ESCAPE '!'", ["%{$escaped}%"]) + ->orWhereRaw("LOWER(name) LIKE ? ESCAPE '!'", ["%{$escaped}%"])) + ->orderByRaw( + "CASE WHEN LOWER(handle) = ? THEN 0 WHEN LOWER(handle) LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END", + [$term, "{$escaped}%", "{$escaped}%"], + ) + ->orderBy('handle') + ->limit($limit) + ->get() + ->toBase(); + } +} diff --git a/app/Services/Mentions.php b/app/Services/Mentions.php index c74a2ffca..07dbba4e8 100644 --- a/app/Services/Mentions.php +++ b/app/Services/Mentions.php @@ -36,11 +36,13 @@ public function handles(string $text): array /** * Finds where each "@handle" is in the text, by the same rules and also skipping code, so a client can link it * to the profile like the web does. The handle keeps its case, and the offset and length count characters in - * the whole text and include the "@". + * the whole text and include the "@". The kind says whether it mentions one of the given organizations, by + * lowercase handle, or anyone else. * - * @return list + * @param array $organizations + * @return list */ - public function positions(string $text): array + public function positions(string $text, array $organizations = []): array { $positions = []; $offset = 0; @@ -54,6 +56,7 @@ public function positions(string $text): array 'handle' => $match[1][0], 'offset' => $offset + mb_strlen(mb_strcut($part, 0, $match[0][1])), 'length' => mb_strlen($match[0][0]), + 'kind' => array_key_exists(mb_strtolower($match[1][0]), $organizations) ? 'organization' : 'user', ]; } } diff --git a/database/factories/ProfileFactory.php b/database/factories/ProfileFactory.php index 94b3210d6..c773f5d61 100644 --- a/database/factories/ProfileFactory.php +++ b/database/factories/ProfileFactory.php @@ -30,4 +30,14 @@ public function definition(): array 'website' => fake()->url(), ]; } + + public function organization(): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => ProfileType::Organization, + 'handle' => fake()->unique()->slug(2), + 'name' => fake()->company(), + 'github_synced_at' => now(), + ]); + } } diff --git a/database/migrations/2026_10_26_200000_create_commit_topics_table.php b/database/migrations/2026_10_26_200000_create_commit_topics_table.php new file mode 100644 index 000000000..f33649a92 --- /dev/null +++ b/database/migrations/2026_10_26_200000_create_commit_topics_table.php @@ -0,0 +1,29 @@ +foreignUuid('commit_id')->constrained()->cascadeOnDelete(); + $table->string('reference'); + $table->string('topic_type'); + $table->uuid('topic_id'); + + $table->primary(['commit_id', 'reference']); + $table->index(['topic_type', 'topic_id', 'commit_id']); + }); + } +}; diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index 6ecabe883..f35f0da0e 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -11,11 +11,13 @@ import type { BodySegment, Commit } from '@/types'; export function CommitBody({ segments, mentions, + organizationMentions = null, className, isStatic = false, }: { segments: BodySegment[]; mentions: Commit['mentions']; + organizationMentions?: Commit['organization_mentions']; className?: string; isStatic?: boolean; }) { @@ -36,6 +38,7 @@ export function CommitBody({ @@ -44,6 +47,7 @@ export function CommitBody({ key={index} segments={block.segments} mentions={mentions} + organizationMentions={organizationMentions} isStatic={isStatic} /> ), @@ -55,10 +59,12 @@ export function CommitBody({ export function BodySegments({ segments, mentions, + organizationMentions = null, isStatic = false, }: { segments: BodySegment[]; mentions: Commit['mentions']; + organizationMentions?: Commit['organization_mentions']; isStatic?: boolean; }) { const { auth } = usePage().props; @@ -73,6 +79,7 @@ export function BodySegments({ key={index} text={emojify(segment.text)} mentions={mentions} + organizationMentions={organizationMentions} pasteisMode={Boolean(auth.pasteis)} isStatic={isStatic} /> @@ -109,11 +116,13 @@ export function FoldedCommitBody({ segments, previewSegments, mentions, + organizationMentions = null, className, }: { segments: BodySegment[]; previewSegments: BodySegment[] | null; mentions: Commit['mentions']; + organizationMentions?: Commit['organization_mentions']; className?: string; }) { const [isUnfolded, setIsUnfolded] = useState(false); @@ -124,6 +133,7 @@ export function FoldedCommitBody({ ); @@ -154,6 +164,7 @@ export function FoldedCommitBody({ : withFullBlocks(previewSegments, segments) } mentions={mentions} + organizationMentions={organizationMentions} className={className} /> {!isUnfolded && ( diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 80b7ae126..9b08b7530 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -455,6 +455,9 @@ export function CommitCard({ diff --git a/resources/js/components/composer-preview.tsx b/resources/js/components/composer-preview.tsx index e6bb61986..7d7801896 100644 --- a/resources/js/components/composer-preview.tsx +++ b/resources/js/components/composer-preview.tsx @@ -19,7 +19,12 @@ import type { BodySegment } from '@/types'; type Mentions = Record; -type Resolved = { body: string; segments: BodySegment[]; mentions: Mentions }; +type Resolved = { + body: string; + segments: BodySegment[]; + mentions: Mentions; + organizationMentions: Mentions; +}; const isModifierEnter = (event: KeyboardEvent) => event.key === 'Enter' && hasModKey(event); @@ -48,7 +53,11 @@ export function useComposerPreview({ const previewId = useId(); const request = useHttp< { body: string }, - { segments: BodySegment[]; mentions: Mentions | [] } + { + segments: BodySegment[]; + mentions: Mentions | []; + organization_mentions: Mentions | []; + } >({ body: '', }); @@ -75,6 +84,11 @@ export function useComposerPreview({ mentions: Array.isArray(response.mentions) ? {} : response.mentions, + organizationMentions: Array.isArray( + response.organization_mentions, + ) + ? {} + : response.organization_mentions, }); } catch { // The preview keeps the code unhighlighted and the mentions as text. @@ -103,6 +117,7 @@ export function useComposerPreview({ draft, segments, mentions: isResolved ? resolved.mentions : null, + organizationMentions: isResolved ? resolved.organizationMentions : null, previewId, toggle: (moveFocus = false) => show(!isPreviewing, moveFocus), close: () => setIsPreviewing(false), @@ -196,6 +211,7 @@ export function ComposerPreviewBody({ ) : ( diff --git a/resources/js/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx index 483164a1a..ed14c7509 100644 --- a/resources/js/components/linkified-text.tsx +++ b/resources/js/components/linkified-text.tsx @@ -21,6 +21,7 @@ import { } from '@/lib/urls'; import { cn, shortHash } from '@/lib/utils'; import { show } from '@/routes/mention'; +import { show as showOrganizationMention } from '@/routes/organization-mention'; import type { BodySegment, Commit } from '@/types'; // A link to a commit on dev/null itself, nothing after its id, so it can show as the author and the commit's hash. @@ -674,6 +675,7 @@ function withMentions( text: string, offset: number, mentions: Commit['mentions'], + organizationMentions: Commit['organization_mentions'], colors: Color[], pasteisMode: boolean, isStatic: boolean, @@ -687,6 +689,10 @@ function withMentions( mentions && Object.hasOwn(mentions, handle) ? mentions[handle] : null; + const organizationId = + organizationMentions && Object.hasOwn(organizationMentions, handle) + ? organizationMentions[handle] + : null; parts.push( ...withColors( @@ -695,9 +701,9 @@ function withMentions( colors, pasteisMode, ), - isStatic || !userId ? ( + isStatic ? ( match[0] - ) : ( + ) : userId ? ( + ) : organizationId ? ( + + {match[0]} + + ) : ( + match[0] ), ); @@ -732,12 +749,14 @@ function withMentions( export function LinkifiedText({ text, mentions, + organizationMentions = null, pasteisMode = false, isStatic = false, startsText = false, }: { text: string; mentions: Commit['mentions']; + organizationMentions?: Commit['organization_mentions']; pasteisMode?: boolean; isStatic?: boolean; // Whether the text is a whole body, so its start is the start of a line. @@ -764,6 +783,7 @@ export function LinkifiedText({ text.slice(position, match.index), position, mentions, + organizationMentions, colors, pasteisMode, isStatic, @@ -808,6 +828,7 @@ export function LinkifiedText({ text.slice(position), position, mentions, + organizationMentions, colors, pasteisMode, isStatic, diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index 199831642..4f7e67b0a 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -4,6 +4,7 @@ import { useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { index as issueSuggestions } from '@/actions/App/Http/Controllers/IssueSuggestionController'; import MentionSuggestionController from '@/actions/App/Http/Controllers/MentionSuggestionController'; +import { OrganizationAvatar } from '@/components/organization-avatar'; import { Spinner } from '@/components/ui/spinner'; import { UserAvatar } from '@/components/user-avatar'; import { typedProse } from '@/lib/code'; @@ -13,7 +14,7 @@ import { issueReferenceBeforeCaret } from '@/lib/issue-reference'; import { suggestionPosition } from '@/lib/suggestion-position'; import { toastWhenThrottled } from '@/lib/throttle'; import { characterCount, cn, laneColor, limitCharacters } from '@/lib/utils'; -import type { OrgMember, User } from '@/types'; +import type { Organization, OrgMember, User } from '@/types'; type Trigger = { kind: 'mention' | 'emoji' | 'issue'; @@ -27,15 +28,17 @@ type IssueSuggestion = { url: string; }; -type Suggestions = { - key: string; +type MentionResults = { users: User[]; + organizations: Organization[]; }; +type Suggestions = MentionResults & { key: string }; + type Position = ReturnType; const debounce = 120; -const cache = new Map(); +const cache = new Map(); export function clearMentionSuggestionsCache(): void { cache.clear(); @@ -175,7 +178,7 @@ async function fetchSuggestions( reply: string | undefined, quote: string | undefined, signal: AbortSignal, -): Promise { +): Promise { const response = await fetch( MentionSuggestionController.index({ query: { q: query, org, reply, quote }, @@ -195,16 +198,16 @@ async function fetchSuggestions( throw new Error('Unable to load suggestions'); } - const data = (await response.json()) as { users: User[] }; + const data = (await response.json()) as MentionResults; // Replies need fresh participants when someone joins the thread. if (!reply) { cache.set( `${org ?? ''}:${reply ?? ''}:${quote ?? ''}:${query.toLowerCase()}`, - data.users, + data, ); } - return data.users; + return data; } /** @@ -356,7 +359,7 @@ export function MentionTextarea({ const timeout = setTimeout( () => fetchSuggestions(query, org, reply, quote, controller.signal) - .then((users) => setResults({ key, users })) + .then((data) => setResults({ key, ...data })) .catch(() => undefined), query === '' ? 0 : debounce, ); @@ -472,12 +475,14 @@ export function MentionTextarea({ .map(({ user }) => user); const current = local !== null && key !== null - ? { key, users: local } + ? { key, users: local, organizations: [] } : key !== null && cached - ? { key, users: cached } + ? { key, ...cached } : results; const suggestions = key !== null && current?.key === key ? current.users : []; + const organizations = + key !== null && current?.key === key ? current.organizations : []; const emojiQuery = trigger?.kind === 'emoji' ? trigger.query.toLowerCase() : ''; const emojis = emojiQuery === '' ? [] : searchEmoji(emojiQuery); @@ -488,7 +493,7 @@ export function MentionTextarea({ : 0 : trigger?.kind === 'emoji' ? emojis.length - : suggestions.length; + : suggestions.length + organizations.length; const selectedIndex = Math.min(activeIndex, Math.max(0, optionCount - 1)); const isOpen = trigger !== null && @@ -497,7 +502,7 @@ export function MentionTextarea({ ? issue !== null : trigger.kind === 'emoji' ? emojis.length > 0 - : suggestions.length > 0 || query !== ''); + : suggestions.length + organizations.length > 0 || query !== ''); const insert = (text: string, separator: string) => { const textarea = textareaRef.current; @@ -548,7 +553,8 @@ export function MentionTextarea({ }); }; - const select = (user: User) => insert(`@${user.handle}`, ' '); + const select = (account: User | Organization) => + insert(`@${account.handle}`, ' '); const selectEmoji = (emoji: EmojiSuggestion) => insert(emoji.emoji, ''); @@ -582,7 +588,11 @@ export function MentionTextarea({ } else if (trigger?.kind === 'emoji') { selectEmoji(emojis[selectedIndex]); } else { - select(suggestions[selectedIndex]); + select( + selectedIndex < suggestions.length + ? suggestions[selectedIndex] + : organizations[selectedIndex - suggestions.length], + ); } return; @@ -612,8 +622,10 @@ export function MentionTextarea({ : trigger?.kind === 'emoji' ? emojis[selectedIndex] && `${id}-emoji-${emojis[selectedIndex].name}` - : suggestions[selectedIndex] && - `${id}-mention-${suggestions[selectedIndex].id}`; + : selectedIndex < suggestions.length + ? `${id}-mention-${suggestions[selectedIndex].id}` + : organizations[selectedIndex - suggestions.length] && + `${id}-mention-${organizations[selectedIndex - suggestions.length].handle}`; const isSearching = current?.key !== key; const listId = @@ -769,7 +781,7 @@ export function MentionTextarea({ ))} - ) : suggestions.length > 0 ? ( + ) : suggestions.length + organizations.length > 0 ? (
    ))} + {organizations.map((organization, index) => ( +
  • + setActiveIndex( + suggestions.length + index, + ) + } + onClick={() => select(organization)} + className={cn( + 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2', + { + 'bg-accent': + suggestions.length + + index === + selectedIndex, + }, + )} + data-test="organization-suggestion" + > + + + + + + + @ + + + + + ↵ + +
  • + ))}
) : (
; + className?: string; + /** + * The image size to fetch: twice the largest size the avatar is drawn at, for high-density screens. + */ + pixels?: number; +}) { + const getInitials = useInitials(); + + return ( + + + + {getInitials(organization.name)} + + + ); +} diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 4114bcfa8..45e3ce0f7 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -698,6 +698,9 @@ function Suggestions({ suggestions }: { suggestions: Commit[] }) {

diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx new file mode 100644 index 000000000..64776f085 --- /dev/null +++ b/resources/js/pages/organization/show.tsx @@ -0,0 +1,106 @@ +import { Head } from '@inertiajs/react'; +import { Link2, MapPin } from 'lucide-react'; +import { show } from '@/actions/App/Http/Controllers/UserController'; +import { BioText } from '@/components/bio-text'; +import { EmptyFeed } from '@/components/commit-feed'; +import { GitHubIcon } from '@/components/github-icon'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { ProfileShareButton } from '@/components/profile-share-button'; +import AppLayout from '@/layouts/app-layout'; +import type { Organization } from '@/types'; + +export default function Show({ organization }: { organization: Organization }) { + return ( + <> + + +
+

+ {organization.name} +

+

+ organization +

+
+ +
+
+ + +
+ +
+
+ +

+ {organization.name} +

+

+ @{organization.handle} + + Organization + +

+ + {organization.bio && ( +

+ +

+ )} + + +
+ +
+ + Organization pages are new. Soon you'll be able to follow @ + {organization.handle} and read what the community commits + about it, right here. + +
+ + ); +} + +Show.layout = AppLayout; diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 4e6b99583..82f791fb8 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -76,6 +76,7 @@ export type Commit = { hot_score: number; body: string; mentions: Record | null; + organization_mentions?: Record | null; body_segments: BodySegment[]; preview_segments: BodySegment[] | null; translation?: CommitTranslation | null; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 0b44a62df..f7facc21a 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -8,5 +8,6 @@ export type * from './log'; export type * from './navigation'; export type * from './notification'; export type * from './operations'; +export type * from './organization'; export type * from './streak'; export type * from './ui'; diff --git a/resources/js/types/organization.ts b/resources/js/types/organization.ts new file mode 100644 index 000000000..8eec366b9 --- /dev/null +++ b/resources/js/types/organization.ts @@ -0,0 +1,8 @@ +export type Organization = { + handle: string; + name: string; + avatar_url: string | null; + bio: string | null; + location: string | null; + website: string | null; +}; diff --git a/routes/web.php b/routes/web.php index 967683c04..2ea4190f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -73,6 +73,8 @@ use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OperationsController; use App\Http\Controllers\OperationsEventController; +use App\Http\Controllers\OrganizationController; +use App\Http\Controllers\OrganizationMentionController; use App\Http\Controllers\OrgArchiveController; use App\Http\Controllers\OrgController; use App\Http\Controllers\OrgMemberController; @@ -134,8 +136,14 @@ use App\Http\Middleware\SerializePushSubscriptions; use App\Http\Middleware\ThrottleCommits; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; +use Illuminate\Http\Request; +use Illuminate\Routing\Middleware\SubstituteBindings; +use Illuminate\Routing\Pipeline; +use Illuminate\Routing\Route as RoutingRoute; +use Illuminate\Routing\Router; use Illuminate\Support\Facades\Route; use Inertia\Inertia; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; // Commit from the terminal: curl -fsSL https://dev-null.sh/commit | bash -s -- 'Hello world!' Route::get('commit', [ScriptController::class, 'show'])->name('cli-script.show'); @@ -190,7 +198,18 @@ // Public pages, readable by guests and crawlers... Route::middleware(RememberIntendedUrl::class)->group(function (): void { - Route::get('@{user:handle}', [UserController::class, 'show'])->name('user.show'); + // A handle no user has may be an organization's. Bindings run before the rest of the route's middleware, like + // the one sharing the page's props, so the organization's page goes through that rest first, like any page. + Route::get('@{user:handle}', [UserController::class, 'show']) + ->missing(function (Request $request): mixed { + $router = resolve(Router::class); + + return new Pipeline(app()) + ->send($request) + ->through(collect($router->gatherRouteMiddleware(resolve(RoutingRoute::class)))->skipUntil(SubstituteBindings::class)->skip(1)->all()) + ->then(fn (Request $request): SymfonyResponse => $router->prepareResponse($request, app()->call(resolve(OrganizationController::class)->show(...)))); + }) + ->name('user.show'); Route::get('@{user:handle}/following', [UserFollowingController::class, 'index']) ->name('user-following.index'); Route::get('@{user:handle}/followers', [UserFollowersController::class, 'index']) @@ -221,6 +240,9 @@ Route::get('mentions/{user}', [MentionController::class, 'show']) ->whereUuid('user') ->name('mention.show'); +Route::get('mentions/organizations/{organization}', [OrganizationMentionController::class, 'show']) + ->whereUuid('organization') + ->name('organization-mention.show'); Route::get('mentions/{user}/preview', [UserPreviewController::class, 'show']) ->middleware('throttle:people') ->whereUuid('user') diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1d9199f10..0b53367a0 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -255,7 +255,7 @@ ['type' => 'code', 'code' => '@Override'], ], 'preview_segments' => null, - 'mentions' => [['handle' => 'nunomaduro', 'offset' => 4, 'length' => 11]], + 'mentions' => [['handle' => 'nunomaduro', 'offset' => 4, 'length' => 11, 'kind' => 'user']], 'status' => 'approved', 'is_pinned' => false, 'starred' => true, diff --git a/tests/Feature/Controllers/Api/ConversationMessageControllerTest.php b/tests/Feature/Controllers/Api/ConversationMessageControllerTest.php index d46bf308b..b1bf6b5f0 100644 --- a/tests/Feature/Controllers/Api/ConversationMessageControllerTest.php +++ b/tests/Feature/Controllers/Api/ConversationMessageControllerTest.php @@ -49,7 +49,7 @@ ['type' => 'code', 'code' => 'the PR'], ['type' => 'text', 'text' => ', @ghost? https://laravel.com'], ]) - ->assertJsonPath('data.messages.0.mentions', [['handle' => 'octocat', 'offset' => 4, 'length' => 8]]) + ->assertJsonPath('data.messages.0.mentions', [['handle' => 'octocat', 'offset' => 4, 'length' => 8, 'kind' => 'user']]) ->assertJsonPath('data.messages.0.link_preview.url', 'https://laravel.com') ->assertJsonPath('data.messages.0.link_preview.is_pending', true) ->assertJsonPath('data.messages.0.reply_to', null) diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index 1e64ae1b9..511d631b5 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -118,7 +118,7 @@ ->assertJsonPath('data.0.stars_count', 2) ->assertJsonPath('data.0.starred', true) ->assertJsonPath('data.0.is_pinned', true) - ->assertJsonPath('data.0.mentions', [['handle' => 'NunoMaduro', 'offset' => 4, 'length' => 11]]) + ->assertJsonPath('data.0.mentions', [['handle' => 'NunoMaduro', 'offset' => 4, 'length' => 11, 'kind' => 'user']]) ->assertJsonPath('data.0.link_preview', [ 'url' => 'https://laravel.com', 'title' => $linkPreview->title, diff --git a/tests/Feature/Controllers/CommitPreviewControllerTest.php b/tests/Feature/Controllers/CommitPreviewControllerTest.php index 9de1453b5..a3c28421d 100644 --- a/tests/Feature/Controllers/CommitPreviewControllerTest.php +++ b/tests/Feature/Controllers/CommitPreviewControllerTest.php @@ -16,7 +16,7 @@ $commit = $user->commits()->sole(); $preview->assertOk() - ->assertExactJson(['segments' => $commit->toArray()['body_segments'], 'mentions' => $commit->mentions]); + ->assertExactJson(['segments' => $commit->toArray()['body_segments'], 'mentions' => $commit->mentions, 'organization_mentions' => []]); }); it('links the mentions of a draft to the people the commit would link', function (): void { diff --git a/tests/Feature/Controllers/MentionSuggestionControllerTest.php b/tests/Feature/Controllers/MentionSuggestionControllerTest.php index 09fd45ab8..cf29f698c 100644 --- a/tests/Feature/Controllers/MentionSuggestionControllerTest.php +++ b/tests/Feature/Controllers/MentionSuggestionControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\Profile; use App\Models\User; it('suggests the people the user follows without a term, most recently followed first', function (): void { @@ -333,3 +334,32 @@ ->assertOk() ->assertJsonPath('users.*.id', [$quote->user_id, $reply->user_id]); }); + +it('suggests organizations that have a page, the closest matches first', function (): void { + $user = User::factory()->create(); + Profile::factory()->organization()->create(['handle' => 'laravel-shift', 'name' => 'Shift']); + Profile::factory()->organization()->create(['handle' => 'laravel', 'name' => 'Laravel']); + Profile::factory()->organization()->create(['handle' => 'tighten', 'name' => 'Laravel Tighten']); + Profile::factory()->organization()->create(['handle' => 'spatie', 'name' => 'Spatie']); + + $this->actingAs($user)->getJson('/search/mentions?q=@Laravel') + ->assertOk() + ->assertJsonPath('organizations.*.handle', ['laravel', 'laravel-shift', 'tighten']) + ->assertJsonMissingPath('organizations.0.github_id'); + + $this->getJson('/search/mentions?q=') + ->assertOk() + ->assertJsonPath('organizations', []); +}); + +it('does not suggest organizations within an org', function (): void { + $mark = User::factory()->create(['handle' => 'mark']); + [$taylor] = followersOf($mark, 1); + Profile::factory()->organization()->create(['handle' => 'laravel']); + $org = createOrg($mark, [$taylor]); + + $this->actingAs($mark) + ->getJson(route('mention-suggestion.index', ['q' => 'laravel', 'org' => $org])) + ->assertOk() + ->assertJsonPath('organizations', []); +}); diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php new file mode 100644 index 000000000..0eb75cbd6 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -0,0 +1,44 @@ +organization()->create([ + 'github_id' => '958072', + 'handle' => 'laravel', + 'name' => 'Laravel', + 'bio' => 'The PHP framework for web artisans.', + 'website' => 'https://laravel.com', + ]); + + $this->get('/@laravel') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('organization/show') + ->where('organization.handle', 'laravel') + ->where('organization.name', 'Laravel') + ->where('organization.bio', 'The PHP framework for web artisans.') + ->where('organization.website', 'https://laravel.com') + ->missing('organization.github_id') + ->missing('organization.github_synced_at')); +}); + +it('does not find handles that are neither a user nor an organization', function (): void { + $this->get('/@nobody')->assertNotFound(); +}); + +it('shares what every page shares, like the signed-in user', function (): void { + $viewer = User::factory()->create(); + Profile::factory()->organization()->create(['handle' => 'laravel']); + + $this->actingAs($viewer)->get('/@laravel') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('organization/show') + ->where('name', config('app.name')) + ->where('auth.user.id', $viewer->id) + ->has('auth.unreadMessagesCount')); +}); diff --git a/tests/Feature/OrganizationMentionsTest.php b/tests/Feature/OrganizationMentionsTest.php new file mode 100644 index 000000000..094d9fde7 --- /dev/null +++ b/tests/Feature/OrganizationMentionsTest.php @@ -0,0 +1,107 @@ +create(); + $tom = User::factory()->create(['handle' => 'tom']); + $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + Profile::factory()->organization()->create(['handle' => 'Tom']); + + $this->actingAs($author)->post('/commits', ['body' => 'Thanks @Laravel and @tom'])->assertRedirect(); + + $commit = $author->commits()->sole(); + + expect($commit->mentions)->toBe(['tom' => $tom->id]) + ->and($commit->organization_mentions)->toBe(['laravel' => $laravel->id]); + + $this->actingAs($author)->get(route('commit.show', [$author, $commit])) + ->assertInertia(fn ($page) => $page->where('commit.organization_mentions.laravel', $laravel->id)); +}); + +it('asks GitHub about the other handles of a commit approved right away', function (): void { + config(['app.admins' => ['admin']]); + Queue::fake(); + + $author = User::factory()->create(['handle' => 'admin']); + + $this->actingAs($author)->post('/commits', ['body' => 'Thanks @laravel'])->assertRedirect(); + + Queue::assertPushed(LinkCommitTopics::class, fn (LinkCommitTopics $job): bool => $job->commit->is($author->commits()->sole())); +}); + +it('does not ask GitHub about commits that only mention users', function (): void { + config(['app.admins' => ['admin']]); + Queue::fake(); + + $author = User::factory()->create(['handle' => 'admin']); + User::factory()->create(['handle' => 'tom']); + + $this->actingAs($author)->post('/commits', ['body' => 'Thanks @tom'])->assertRedirect(); + + Queue::assertNotPushed(LinkCommitTopics::class); +}); + +it('waits for the approval of a pending commit to ask GitHub', function (): void { + config(['app.admins' => ['moderator']]); + Queue::fake(); + + $commit = Commit::factory()->pending()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + $moderator = User::factory()->create(['handle' => 'moderator']); + $other = Commit::factory()->pending()->create(['body' => 'Thanks', 'mentions' => []]); + + Queue::assertNotPushed(LinkCommitTopics::class); + + $this->actingAs($moderator)->post(route('commit-approval.store', $commit))->assertRedirect(); + $this->actingAs($moderator)->post(route('commit-approval.store', $other))->assertRedirect(); + + Queue::assertPushed(LinkCommitTopics::class, 1); + Queue::assertPushed(LinkCommitTopics::class, fn (LinkCommitTopics $job): bool => $job->commit->is($commit)); +}); + +it('links mentions to the organization they captured, even after it is renamed', function (): void { + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + + $this->get(route('organization-mention.show', $organization))->assertRedirect('/@laravel'); + + $organization->update(['handle' => 'laravel-renamed']); + + $this->get(route('organization-mention.show', $organization))->assertRedirect('/@laravel-renamed'); +}); + +it('previews which mentions are organizations', function (): void { + $user = User::factory()->create(); + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + + $this->actingAs($user)->postJson(route('commit-preview.store'), ['body' => 'Thanks @laravel']) + ->assertOk() + ->assertJsonPath('organization_mentions', ['laravel' => $organization->id]); +}); + +it('tells API clients which mentions are organizations', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $commit = Commit::factory()->create(['body' => '@laravel and @tom', 'mentions' => []]); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); + + signedApiRequest($keyPair, 'GET', route('api-log.show', 'latest')) + ->assertOk() + ->assertJsonPath('data.0.mentions', [ + ['handle' => 'laravel', 'offset' => 0, 'length' => 8, 'kind' => 'organization'], + ['handle' => 'tom', 'offset' => 13, 'length' => 4, 'kind' => 'user'], + ]); + + signedApiRequest($keyPair, 'POST', route('api-commit-preview.store'), ['body' => 'Hi @Laravel']) + ->assertOk() + ->assertJsonPath('data.mentions', [['handle' => 'Laravel', 'offset' => 3, 'length' => 8, 'kind' => 'organization']]); +}); diff --git a/tests/Unit/Actions/CreateCommitTopicsTest.php b/tests/Unit/Actions/CreateCommitTopicsTest.php new file mode 100644 index 000000000..df5674394 --- /dev/null +++ b/tests/Unit/Actions/CreateCommitTopicsTest.php @@ -0,0 +1,28 @@ +organization()->create(['handle' => 'laravel']); + $digits = Profile::factory()->organization()->create(['handle' => '1337']); + $commit = Commit::factory()->create(['body' => '@Laravel and @1337', 'mentions' => []]); + + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $laravel->id, '1337' => $digits->id]); + + expect($commit->refresh()->organization_mentions)->toHaveCount(2)->toMatchArray(['laravel' => $laravel->id, '1337' => $digits->id]); +}); + +it('keeps what a reference already links to', function (): void { + $first = Profile::factory()->organization()->create(); + $second = Profile::factory()->organization()->create(); + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $first->id]); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $second->id]); + + expect($commit->refresh()->organization_mentions)->toBe(['laravel' => $first->id]); +}); diff --git a/tests/Unit/Actions/DeleteCommitContentTest.php b/tests/Unit/Actions/DeleteCommitContentTest.php index 2671aa2c9..9eeef6d0a 100644 --- a/tests/Unit/Actions/DeleteCommitContentTest.php +++ b/tests/Unit/Actions/DeleteCommitContentTest.php @@ -3,12 +3,14 @@ declare(strict_types=1); use App\Actions\CreateCommitRestoration; +use App\Actions\CreateCommitTopics; use App\Actions\DeleteCommit; use App\Actions\DeleteCommitContent; use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\Poll; +use App\Models\Profile; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -73,7 +75,8 @@ function deletedCommitWithImage(): CommitImage }); it('erases the content once the undo window has closed', function (): void { - $commit = Commit::factory()->create(['body' => 'Hello @taylor', 'mentions' => ['taylor'], 'link_preview_id' => LinkPreview::factory()]); + $commit = Commit::factory()->create(['body' => 'Hello @taylor and @laravel', 'mentions' => ['taylor'], 'link_preview_id' => LinkPreview::factory()]); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => Profile::factory()->organization()->create()->id]); $poll = Poll::factory()->withOptions('Yes', 'No')->create(['commit_id' => $commit->id]); resolve(DeleteCommit::class)->handle($commit); $this->travel(30)->seconds(); @@ -84,9 +87,11 @@ function deletedCommitWithImage(): CommitImage expect($commit) ->body->toBe('') ->mentions->toBeNull() + ->organization_mentions->toBeNull() ->link_preview_id->toBeNull() ->user_id->not->toBeNull(); $this->assertModelMissing($poll); + $this->assertDatabaseMissing('commit_topics', ['commit_id' => $commit->id]); }); it('erases the previews of links to the commit, which hold its body', function (): void { diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index df3242c95..8948afcdc 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Actions\CreateCommitTopics; use App\Actions\CreateCommitTranslation; use App\Actions\DeleteUser; use App\Actions\UpdateCommitHotScores; @@ -19,6 +20,7 @@ use App\Models\Poll; use App\Models\PollOption; use App\Models\PollVote; +use App\Models\Profile; use App\Models\PushSubscription; use App\Models\User; use App\Services\CommitTranslations; @@ -85,10 +87,11 @@ $mentioned = User::factory()->create(); $commit = Commit::factory()->create(); $reply = Commit::factory()->for($user)->for($commit, 'parent')->for(LinkPreview::factory())->create([ - 'body' => "@{$mentioned->handle} have a look at https://example.com", + 'body' => "@{$mentioned->handle} and @laravel have a look at https://example.com", 'mentions' => [$mentioned->handle => $mentioned->id], 'embedding' => embeddingVector(0), ]); + resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => Profile::factory()->organization()->create()->id]); $deletedReply = Commit::factory()->for($user)->for($commit, 'parent')->create(); Commit::factory()->for($reply, 'parent')->create(); Commit::factory()->for($deletedReply, 'parent')->create(); @@ -101,6 +104,7 @@ expect($tombstones->count())->toBe(2) ->and($tombstones->pluck('body')->all())->toBe(['', '']) ->and($tombstones->pluck('mentions')->all())->toBe([null, null]) + ->and(DB::table('commit_topics')->where('commit_id', $reply->id)->exists())->toBeFalse() ->and($tombstones->pluck('embedding')->all())->toBe([null, null]) ->and($tombstones->pluck('link_preview_id')->all())->toBe([null, null]); }); diff --git a/tests/Unit/Actions/UpdateCommitTopicsTest.php b/tests/Unit/Actions/UpdateCommitTopicsTest.php new file mode 100644 index 000000000..fa3a43c33 --- /dev/null +++ b/tests/Unit/Actions/UpdateCommitTopicsTest.php @@ -0,0 +1,210 @@ + $account + * @return array + */ +function gitHubOrganization(array $account = []): array +{ + return [ + 'login' => 'laravel', + 'id' => 958072, + 'type' => 'Organization', + 'name' => 'Laravel', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072?v=4', + 'bio' => 'The PHP framework for web artisans.', + 'location' => null, + 'blog' => 'laravel.com', + 'company' => null, + ...$account, + ]; +} + +it('links the organizations GitHub has to the commit, from their GitHub profile', function (): void { + Http::fake([ + 'api.github.com/users/laravel' => Http::response(gitHubOrganization()), + ]); + + $commit = Commit::factory()->create(['body' => 'Thanks @Laravel!', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + $organization = Profile::query()->where('type', ProfileType::Organization)->sole(); + + expect($organization) + ->github_id->toBe('958072') + ->handle->toBe('laravel') + ->name->toBe('Laravel') + ->avatar_url->toBe('https://avatars.githubusercontent.com/u/958072?v=4') + ->bio->toBe('The PHP framework for web artisans.') + ->location->toBeNull() + ->website->toBe('https://laravel.com') + ->and($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); +}); + +it('only asks GitHub about the handles that are neither a user nor a known organization', function (): void { + Http::fake(); + + $tom = User::factory()->create(['handle' => 'tom']); + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $commit = Commit::factory()->create(['body' => 'Hi @tom, @laravel', 'mentions' => ['tom' => $tom->id]]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertNothingSent(); + expect($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); +}); + +it('does nothing for a commit that only mentions users', function (): void { + Http::fake(); + + $tom = User::factory()->create(['handle' => 'tom']); + $commit = Commit::factory()->create(['body' => 'Hi @tom', 'mentions' => ['tom' => $tom->id]]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertNothingSent(); + expect($commit->refresh()->organization_mentions)->toBeNull(); +}); + +it('does not ask about a handle again for a day once it is not an organization', function (): void { + Http::fake([ + 'api.github.com/users/octocat' => Http::response(gitHubOrganization(['login' => 'octocat', 'id' => 583231, 'type' => 'User'])), + 'api.github.com/users/nobody' => Http::response(status: 404), + ]); + + $commit = Commit::factory()->create(['body' => '@octocat @nobody', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(2); + expect(Profile::query()->where('type', ProfileType::Organization)->count())->toBe(0) + ->and($commit->refresh()->organization_mentions)->toBeNull(); + + $this->travel(2)->days(); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(4); +}); + +it('refreshes a known organization once its profile is a day old', function (): void { + Http::fake([ + 'api.github.com/user/958072' => Http::response(gitHubOrganization(['name' => 'Laravel, Inc.'])), + ]); + + $organization = Profile::factory()->organization()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subHours(Profile::FRESH_FOR_HOURS)->subMinute()]); + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + expect($organization->refresh()) + ->name->toBe('Laravel, Inc.') + ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()) + ->and($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); +}); + +it('deletes a known organization GitHub no longer has, and unlinks the commits that mention it', function (): void { + Http::fake([ + 'api.github.com/user/958072' => Http::response(status: 404), + ]); + + $organization = Profile::factory()->organization()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subDays(2)]); + $earlier = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + resolve(CreateCommitTopics::class)->handle($earlier, ['laravel' => $organization->id]); + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + expect(Profile::query()->where('type', ProfileType::Organization)->count())->toBe(0) + ->and($commit->refresh()->organization_mentions)->toBeNull() + ->and($earlier->refresh()->organization_mentions)->toBeNull(); +}); + +it('keeps the organizations the commit already links to', function (): void { + Http::fake([ + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + $organization = Profile::factory()->organization()->create(['handle' => 'now-renamed']); + $commit = Commit::factory()->create(['body' => '@renamed', 'mentions' => []]); + resolve(CreateCommitTopics::class)->handle($commit, ['renamed' => $organization->id]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + expect($commit->refresh()->organization_mentions)->toBe(['renamed' => $organization->id]); +}); + +it('asks GitHub about five handles per commit at most', function (): void { + Http::fake([ + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + $commit = Commit::factory()->create(['body' => '@one @two @three @four @five @six', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(5); +}); + +it('waits for the rate limit to reset once few requests are left', function (): void { + $resetsAt = now()->addMinutes(30)->startOfSecond(); + + Http::fake([ + 'api.github.com/users/laravel' => Http::response(gitHubOrganization(), headers: [ + 'X-RateLimit-Remaining' => '999', + 'X-RateLimit-Reset' => (string) $resetsAt->getTimestamp(), + ]), + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + $commit = Commit::factory()->create(['body' => '@laravel @symfony', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(1); + expect($commit->refresh()->organization_mentions)->toHaveKey('laravel') + ->and(Cache::has('github-organizations:paused'))->toBeTrue(); + + $this->travelTo($resetsAt->addSecond()); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(2); +}); + +it('keeps asking while enough requests are left', function (): void { + Http::fake([ + 'api.github.com/users/*' => Http::response(status: 404, headers: ['X-RateLimit-Remaining' => '1000']), + ]); + + $commit = Commit::factory()->create(['body' => '@laravel @symfony', 'mentions' => []]); + + resolve(UpdateCommitTopics::class)->handle($commit); + + Http::assertSentCount(2); + expect(Cache::has('github-organizations:paused'))->toBeFalse(); +}); + +it('throws when GitHub fails, so the job tries again', function (): void { + Http::fake([ + 'api.github.com/users/laravel' => Http::response(status: 502), + ]); + + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + expect(fn () => resolve(UpdateCommitTopics::class)->handle($commit))->toThrow(RequestException::class); +}); diff --git a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php new file mode 100644 index 000000000..d46ddd34f --- /dev/null +++ b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php @@ -0,0 +1,39 @@ +organization()->create(['github_id' => '958072', 'handle' => 'laravel-old', 'github_synced_at' => now()->subWeek()]); + + $updated = resolve(UpdateOrCreateOrganization::class)->handle([ + 'login' => 'laravel', + 'id' => 958072, + 'name' => null, + 'avatar_url' => null, + 'bio' => null, + 'location' => 'Little Rock', + 'blog' => null, + ]); + + expect($updated->id)->toBe($organization->id) + ->and($organization->refresh()) + ->handle->toBe('laravel') + ->name->toBe('laravel') + ->avatar_url->toBeNull() + ->location->toBe('Little Rock') + ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()); +}); + +it('gives the handle up when another profile renamed away from it on GitHub', function (): void { + $stale = Profile::factory()->organization()->create(['github_id' => '1', 'handle' => 'Laravel']); + $other = Profile::factory()->organization()->create(['github_id' => '2', 'handle' => 'symfony']); + + $organization = resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072]); + + expect($organization->handle)->toBe('laravel') + ->and($stale->refresh()->handle)->toBe('_renamed-1') + ->and($other->refresh()->handle)->toBe('symfony'); +}); diff --git a/tests/Unit/Jobs/LinkCommitTopicsTest.php b/tests/Unit/Jobs/LinkCommitTopicsTest.php new file mode 100644 index 000000000..bc4e2f1de --- /dev/null +++ b/tests/Unit/Jobs/LinkCommitTopicsTest.php @@ -0,0 +1,19 @@ +organization()->create(['handle' => 'laravel']); + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + + dispatch_sync(new LinkCommitTopics($commit)); + + expect($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); +}); diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index 0d2c461ec..a7508c76c 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -115,6 +115,44 @@ test('keeps stable mention links alongside shortened URLs and their full copyabl assert.equal(html.replace(/<[^>]*>/g, '').replace('…', ''), text); }); +test('links captured organization mentions to their page, a user of the same name first', () => { + const userId = '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b'; + const organizationId = '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c'; + const html = renderToStaticMarkup( + , + ); + + assert.ok( + html.includes(`href="/mentions/organizations/${organizationId}"`), + ); + assert.ok(html.includes('>@Laravel')); + assert.equal(html.match(/data-test="organization-mention"/g)?.length, 1); + assert.ok(html.includes(`href="/mentions/${userId}"`)); + assert.equal(html.match(/data-test="mention"/g)?.length, 1); +}); + +test('leaves organization mentions as plain text in static bodies', () => { + const html = renderToStaticMarkup( + , + ); + + assert.equal(html, 'Thanks @laravel!'); +}); + test('leaves mentions without captured identities as plain text', () => { const html = renderToStaticMarkup( , diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index e547239e6..576130e08 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -29,6 +29,7 @@ 'root_id', 'hot_score', 'pasteis_count', + 'organization_mentions', 'body_segments', 'preview_segments', 'is_checking', diff --git a/tests/Unit/Queries/CommitBuilderTest.php b/tests/Unit/Queries/CommitBuilderTest.php index 0bf0b3559..37b01d8d5 100644 --- a/tests/Unit/Queries/CommitBuilderTest.php +++ b/tests/Unit/Queries/CommitBuilderTest.php @@ -23,9 +23,9 @@ DB::disableQueryLog(); }); -it('loads commits with every column but the embedding, which stays stored', function (): void { +it('loads commits with every column but the embedding, which stays stored, and the organizations they mention', function (): void { $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]); - $columns = array_values(array_diff(Schema::getColumnListing('commits'), ['embedding'])); + $columns = [...array_values(array_diff(Schema::getColumnListing('commits'), ['embedding'])), 'organization_mentions']; expect(array_keys(Commit::query()->findOrFail($commit->id)->getAttributes()))->toBe($columns) ->and(array_keys(Commit::query()->withStarsFor(null)->findOrFail($commit->id)->getAttributes()))->toBe([...$columns, 'stars_count', 'starred']) diff --git a/tests/Unit/Services/MentionsTest.php b/tests/Unit/Services/MentionsTest.php index 43bcc990a..9ef2e274b 100644 --- a/tests/Unit/Services/MentionsTest.php +++ b/tests/Unit/Services/MentionsTest.php @@ -58,9 +58,9 @@ $mentions = resolve(Mentions::class); expect($mentions->positions('@NunoMaduro and @taylorotwell, thanks! cc @nunomaduro'))->toBe([ - ['handle' => 'NunoMaduro', 'offset' => 0, 'length' => 11], - ['handle' => 'taylorotwell', 'offset' => 16, 'length' => 13], - ['handle' => 'nunomaduro', 'offset' => 42, 'length' => 11], + ['handle' => 'NunoMaduro', 'offset' => 0, 'length' => 11, 'kind' => 'user'], + ['handle' => 'taylorotwell', 'offset' => 16, 'length' => 13, 'kind' => 'user'], + ['handle' => 'nunomaduro', 'offset' => 42, 'length' => 11, 'kind' => 'user'], ]); }); @@ -68,7 +68,7 @@ $mentions = resolve(Mentions::class); expect($mentions->positions('Café 🎉 with @nuno.'))->toBe([ - ['handle' => 'nuno', 'offset' => 12, 'length' => 5], + ['handle' => 'nuno', 'offset' => 12, 'length' => 5, 'kind' => 'user'], ]); }); @@ -82,8 +82,8 @@ $mentions = resolve(Mentions::class); expect($mentions->positions("thanks @taylorotwell for `@Override`, @nuno\n```java\n@Deprecated\nvoid run() {}\n```\ncc @jess-archer"))->toBe([ - ['handle' => 'taylorotwell', 'offset' => 7, 'length' => 13], - ['handle' => 'nuno', 'offset' => 38, 'length' => 5], - ['handle' => 'jess-archer', 'offset' => 85, 'length' => 12], + ['handle' => 'taylorotwell', 'offset' => 7, 'length' => 13, 'kind' => 'user'], + ['handle' => 'nuno', 'offset' => 38, 'length' => 5, 'kind' => 'user'], + ['handle' => 'jess-archer', 'offset' => 85, 'length' => 12, 'kind' => 'user'], ]); }); From 241f93fff878928f5079eb42e9ecc1b9639832ad Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 16:00:52 +0100 Subject: [PATCH 2/9] fix: look organizations up again once the GitHub rate limit resets --- app/Actions/UpdateCommitTopics.php | 19 +++++++++---- app/Jobs/LinkCommitTopics.php | 10 +++++-- tests/Unit/Actions/UpdateCommitTopicsTest.php | 13 +++++---- tests/Unit/Jobs/LinkCommitTopicsTest.php | 27 +++++++++++++++++++ 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php index 38f4094ee..337595894 100644 --- a/app/Actions/UpdateCommitTopics.php +++ b/app/Actions/UpdateCommitTopics.php @@ -43,14 +43,14 @@ public function __construct( * Asks GitHub about the handles the commit mentions that are neither a user nor a known organization, and * refreshes the known organizations it mentions once their profile is a day old, then links the commit to them. * A handle that isn't an organization's isn't asked about again for a day, and an organization GitHub no longer - * has is deleted, with the links to it. + * has is deleted, with the links to it. Returns when to try again if the rate limit cut the lookups short. */ - public function handle(Commit $commit): void + public function handle(Commit $commit): ?CarbonImmutable { $handles = $commit->mentionedNonUsers(); if ($handles === []) { - return; + return null; } $known = Profile::query() @@ -78,8 +78,14 @@ function () use ($organization): void { ->values() ->all(); + $retryAt = null; + foreach ($lookups as [$path, $notFound]) { - if (Cache::has('github-organizations:paused')) { + $pausedUntil = Cache::get('github-organizations:paused'); + + if (is_int($pausedUntil)) { + $retryAt = CarbonImmutable::createFromTimestamp($pausedUntil); + break; } @@ -87,7 +93,8 @@ function () use ($organization): void { $remaining = $response->header('X-RateLimit-Remaining'); if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) { - Cache::put('github-organizations:paused', true, CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset'))); + $resetsAt = CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset')); + Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); } if ($response->notFound() || $response->throw()->json('type') !== 'Organization') { @@ -103,5 +110,7 @@ function () use ($organization): void { } $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($commit->body, $commit->mentions ?? [])); + + return $retryAt; } } diff --git a/app/Jobs/LinkCommitTopics.php b/app/Jobs/LinkCommitTopics.php index 5b343a9c1..9d551cd12 100644 --- a/app/Jobs/LinkCommitTopics.php +++ b/app/Jobs/LinkCommitTopics.php @@ -7,6 +7,7 @@ use App\Actions\UpdateCommitTopics; use App\Enums\CommitStatus; use App\Models\Commit; +use Carbon\CarbonImmutable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\Backoff; @@ -14,7 +15,8 @@ use Illuminate\Queue\Attributes\Tries; /** - * Runs once a commit is approved, so only commits everyone can read spend GitHub's rate limit. + * Runs once a commit is approved, so only commits everyone can read spend GitHub's rate limit, and again once the rate + * limit resets when it cut the lookups short. */ #[Backoff([60, 600])] #[Tries(3)] @@ -49,6 +51,10 @@ public static function dispatchFor(Commit ...$commits): void public function handle(UpdateCommitTopics $action): void { - $action->handle($this->commit); + $retryAt = $action->handle($this->commit); + + if ($retryAt instanceof CarbonImmutable) { + $this->release($retryAt); + } } } diff --git a/tests/Unit/Actions/UpdateCommitTopicsTest.php b/tests/Unit/Actions/UpdateCommitTopicsTest.php index fa3a43c33..eb684c1cf 100644 --- a/tests/Unit/Actions/UpdateCommitTopicsTest.php +++ b/tests/Unit/Actions/UpdateCommitTopicsTest.php @@ -160,7 +160,7 @@ function gitHubOrganization(array $account = []): array Http::assertSentCount(5); }); -it('waits for the rate limit to reset once few requests are left', function (): void { +it('waits for the rate limit to reset once few requests are left, and says when to try again', function (): void { $resetsAt = now()->addMinutes(30)->startOfSecond(); Http::fake([ @@ -173,16 +173,16 @@ function gitHubOrganization(array $account = []): array $commit = Commit::factory()->create(['body' => '@laravel @symfony', 'mentions' => []]); - resolve(UpdateCommitTopics::class)->handle($commit); + $retryAt = resolve(UpdateCommitTopics::class)->handle($commit); Http::assertSentCount(1); - expect($commit->refresh()->organization_mentions)->toHaveKey('laravel') + expect($retryAt?->getTimestamp())->toBe($resetsAt->getTimestamp()) + ->and($commit->refresh()->organization_mentions)->toHaveKey('laravel') ->and(Cache::has('github-organizations:paused'))->toBeTrue(); $this->travelTo($resetsAt->addSecond()); - resolve(UpdateCommitTopics::class)->handle($commit); - + expect(resolve(UpdateCommitTopics::class)->handle($commit))->toBeNull(); Http::assertSentCount(2); }); @@ -193,8 +193,7 @@ function gitHubOrganization(array $account = []): array $commit = Commit::factory()->create(['body' => '@laravel @symfony', 'mentions' => []]); - resolve(UpdateCommitTopics::class)->handle($commit); - + expect(resolve(UpdateCommitTopics::class)->handle($commit))->toBeNull(); Http::assertSentCount(2); expect(Cache::has('github-organizations:paused'))->toBeFalse(); }); diff --git a/tests/Unit/Jobs/LinkCommitTopicsTest.php b/tests/Unit/Jobs/LinkCommitTopicsTest.php index bc4e2f1de..47e52baef 100644 --- a/tests/Unit/Jobs/LinkCommitTopicsTest.php +++ b/tests/Unit/Jobs/LinkCommitTopicsTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Actions\UpdateCommitTopics; use App\Jobs\LinkCommitTopics; use App\Models\Commit; use App\Models\Profile; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; it('links the organizations the commit mentions', function (): void { @@ -17,3 +19,28 @@ expect($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); }); + +it('runs again once the rate limit resets when it cut the lookups short', function (): void { + Http::fake(); + + $resetsAt = now()->addMinutes(30)->startOfSecond(); + Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + $job = new LinkCommitTopics($commit)->withFakeQueueInteractions(); + + $job->handle(resolve(UpdateCommitTopics::class)); + + Http::assertNothingSent(); + $job->assertReleased($resetsAt); +}); + +it('is done once it looked every handle up', function (): void { + Http::fake(['api.github.com/users/*' => Http::response(status: 404)]); + + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + $job = new LinkCommitTopics($commit)->withFakeQueueInteractions(); + + $job->handle(resolve(UpdateCommitTopics::class)); + + $job->assertNotReleased(); +}); From 09a25bfe965a41fd62b451fb3beb8d59e7e8ec65 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 16:05:31 +0100 Subject: [PATCH 3/9] fix: read the GitHub pause from any cache store --- app/Actions/UpdateCommitTopics.php | 4 +-- tests/Unit/Actions/UpdateCommitTopicsTest.php | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php index 337595894..4005444bb 100644 --- a/app/Actions/UpdateCommitTopics.php +++ b/app/Actions/UpdateCommitTopics.php @@ -83,8 +83,8 @@ function () use ($organization): void { foreach ($lookups as [$path, $notFound]) { $pausedUntil = Cache::get('github-organizations:paused'); - if (is_int($pausedUntil)) { - $retryAt = CarbonImmutable::createFromTimestamp($pausedUntil); + if (is_numeric($pausedUntil)) { + $retryAt = CarbonImmutable::createFromTimestamp((int) $pausedUntil); break; } diff --git a/tests/Unit/Actions/UpdateCommitTopicsTest.php b/tests/Unit/Actions/UpdateCommitTopicsTest.php index eb684c1cf..b5d0cce66 100644 --- a/tests/Unit/Actions/UpdateCommitTopicsTest.php +++ b/tests/Unit/Actions/UpdateCommitTopicsTest.php @@ -186,6 +186,31 @@ function gitHubOrganization(array $account = []): array Http::assertSentCount(2); }); +it('does not try again when the last lookup is the one that pauses', function (): void { + Http::fake([ + 'api.github.com/users/laravel' => Http::response(gitHubOrganization(), headers: [ + 'X-RateLimit-Remaining' => '999', + 'X-RateLimit-Reset' => (string) now()->addMinutes(30)->getTimestamp(), + ]), + ]); + + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + expect(resolve(UpdateCommitTopics::class)->handle($commit))->toBeNull() + ->and(Cache::has('github-organizations:paused'))->toBeTrue(); +}); + +it('reads the pause from cache stores that keep numbers as strings', function (): void { + Http::fake(); + + $resetsAt = now()->addMinutes(30)->startOfSecond(); + Cache::put('github-organizations:paused', (string) $resetsAt->getTimestamp(), $resetsAt); + $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); + + expect(resolve(UpdateCommitTopics::class)->handle($commit)?->getTimestamp())->toBe($resetsAt->getTimestamp()); + Http::assertNothingSent(); +}); + it('keeps asking while enough requests are left', function (): void { Http::fake([ 'api.github.com/users/*' => Http::response(status: 404, headers: ['X-RateLimit-Remaining' => '1000']), From 4cb9b8ee2c04956b998c7e7f19e4257d55f862b1 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 16:08:28 +0100 Subject: [PATCH 4/9] feat: link the commits written before organizations existed, with commits:link-organizations --- .../LinkCommitOrganizationsCommand.php | 58 +++++++++++++++++ .../Commands/LinkCommitOrganizationsTest.php | 63 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 app/Console/Commands/LinkCommitOrganizationsCommand.php create mode 100644 tests/Feature/Commands/LinkCommitOrganizationsTest.php diff --git a/app/Console/Commands/LinkCommitOrganizationsCommand.php b/app/Console/Commands/LinkCommitOrganizationsCommand.php new file mode 100644 index 000000000..04b5b5625 --- /dev/null +++ b/app/Console/Commands/LinkCommitOrganizationsCommand.php @@ -0,0 +1,58 @@ +approved()->where('body', 'like', '%@%')->lazyById() as $commit) { + if ($commit->mentionedNonUsers() === []) { + continue; + } + + try { + $retryAt = $updateCommitTopics->handle($commit); + } catch (RequestException|ConnectionException $exception) { + report($exception); + + $this->components->warn("Could not look up the organizations of commit {$commit->id}."); + + $failed++; + + continue; + } + + if ($retryAt instanceof CarbonImmutable) { + $this->components->error("GitHub rate limit reached after {$linked} commits. Run the command again after {$retryAt->toDateTimeString()} UTC."); + + return self::FAILURE; + } + + $linked++; + } + + $this->components->info("Looked up the organizations of {$linked} commits."); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } +} diff --git a/tests/Feature/Commands/LinkCommitOrganizationsTest.php b/tests/Feature/Commands/LinkCommitOrganizationsTest.php new file mode 100644 index 000000000..40f30251e --- /dev/null +++ b/tests/Feature/Commands/LinkCommitOrganizationsTest.php @@ -0,0 +1,63 @@ + Http::response(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]), + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + $tom = User::factory()->create(['handle' => 'tom']); + $first = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + $second = Commit::factory()->create(['body' => '@Laravel and @nobody', 'mentions' => []]); + $pending = Commit::factory()->pending()->create(['body' => '@pending-org', 'mentions' => []]); + Commit::factory()->create(['body' => 'Hi @tom', 'mentions' => ['tom' => $tom->id]]); + Commit::factory()->create(['body' => 'No mention']); + + $this->artisan('commits:link-organizations') + ->expectsOutputToContain('Looked up the organizations of 2 commits.') + ->assertSuccessful(); + + $laravel = Profile::query()->where('handle', 'laravel')->sole(); + + expect($first->refresh()->organization_mentions)->toBe(['laravel' => $laravel->id]) + ->and($second->refresh()->organization_mentions)->toBe(['laravel' => $laravel->id]) + ->and($pending->refresh()->organization_mentions)->toBeNull(); + Http::assertSentCount(2); +}); + +it('stops once GitHub pauses the lookups, to be run again after the reset', function (): void { + Http::fake(); + + $resetsAt = now()->addMinutes(30)->startOfSecond(); + Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); + Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + + $this->artisan('commits:link-organizations') + ->expectsOutputToContain("GitHub rate limit reached after 0 commits. Run the command again after {$resetsAt->toDateTimeString()} UTC.") + ->assertFailed(); + + Http::assertNothingSent(); +}); + +it('goes on with the other commits when GitHub fails for one', function (): void { + Http::fake([ + 'api.github.com/users/broken' => Http::response(status: 502), + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + $broken = Commit::factory()->create(['body' => '@broken', 'mentions' => []]); + Commit::factory()->create(['body' => '@nobody', 'mentions' => []]); + + $this->artisan('commits:link-organizations') + ->expectsOutputToContain("Could not look up the organizations of commit {$broken->id}.") + ->expectsOutputToContain('Looked up the organizations of 1 commits.') + ->assertFailed(); +}); From e8b92d359715f68756327c46734cd01ba24deffe Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 16:13:00 +0100 Subject: [PATCH 5/9] fix: stop the organization backfill when GitHub refuses requests --- app/Actions/UpdateCommitTopics.php | 6 +++--- .../Commands/LinkCommitOrganizationsCommand.php | 6 ++++++ .../Commands/LinkCommitOrganizationsTest.php | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php index 4005444bb..56ec00fee 100644 --- a/app/Actions/UpdateCommitTopics.php +++ b/app/Actions/UpdateCommitTopics.php @@ -81,10 +81,10 @@ function () use ($organization): void { $retryAt = null; foreach ($lookups as [$path, $notFound]) { - $pausedUntil = Cache::get('github-organizations:paused'); + $pausedUntil = Cache::integer('github-organizations:paused', 0); - if (is_numeric($pausedUntil)) { - $retryAt = CarbonImmutable::createFromTimestamp((int) $pausedUntil); + if ($pausedUntil > 0) { + $retryAt = CarbonImmutable::createFromTimestamp($pausedUntil); break; } diff --git a/app/Console/Commands/LinkCommitOrganizationsCommand.php b/app/Console/Commands/LinkCommitOrganizationsCommand.php index 04b5b5625..b2bf56623 100644 --- a/app/Console/Commands/LinkCommitOrganizationsCommand.php +++ b/app/Console/Commands/LinkCommitOrganizationsCommand.php @@ -33,6 +33,12 @@ public function handle(UpdateCommitTopics $updateCommitTopics): int try { $retryAt = $updateCommitTopics->handle($commit); } catch (RequestException|ConnectionException $exception) { + if ($exception instanceof RequestException && in_array($exception->response->status(), [403, 429], true)) { + $this->components->error("GitHub rate limit reached after {$linked} commits. Run the command again later."); + + return self::FAILURE; + } + report($exception); $this->components->warn("Could not look up the organizations of commit {$commit->id}."); diff --git a/tests/Feature/Commands/LinkCommitOrganizationsTest.php b/tests/Feature/Commands/LinkCommitOrganizationsTest.php index 40f30251e..324a40a31 100644 --- a/tests/Feature/Commands/LinkCommitOrganizationsTest.php +++ b/tests/Feature/Commands/LinkCommitOrganizationsTest.php @@ -47,6 +47,21 @@ Http::assertNothingSent(); }); +it('stops when GitHub refuses the requests', function (): void { + Http::fake([ + 'api.github.com/users/*' => Http::response(status: 429), + ]); + + Commit::factory()->create(['body' => '@first', 'mentions' => []]); + Commit::factory()->create(['body' => '@second', 'mentions' => []]); + + $this->artisan('commits:link-organizations') + ->expectsOutputToContain('GitHub rate limit reached after 0 commits. Run the command again later.') + ->assertFailed(); + + Http::assertSentCount(1); +}); + it('goes on with the other commits when GitHub fails for one', function (): void { Http::fake([ 'api.github.com/users/broken' => Http::response(status: 502), From 3514d108af3e648cdf7942e432121c89667fa8f1 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 16:49:39 +0100 Subject: [PATCH 6/9] refactor: delete commit topics in the actions instead of cascading --- app/Actions/DeleteUser.php | 16 ++++++++++++---- ...6_10_26_200000_create_commit_topics_table.php | 2 +- tests/Unit/Actions/DeleteUserTest.php | 9 +++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index be9720678..3bbeae5cb 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -43,13 +43,12 @@ public function handle(User $user): void $imagePaths = [...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)]; - $translationKeys = Commit::query()->withTrashed() - ->where(fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id) - ->orWhereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))) + $translationKeys = $this->touchedCommits($user) ->select(['id', 'body']) ->lazyById(500) ->map(resolve(CommitTranslations::class)->key(...))->unique()->all(); + DB::table('commit_topics')->whereIn('commit_id', $this->touchedCommits($user)->select('id'))->delete(); $user->commits()->withTrashed()->topLevel()->forceDelete(); CommitImage::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); CommitReport::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); @@ -59,7 +58,6 @@ public function handle(User $user): void } $user->commits()->delete(); - DB::table('commit_topics')->whereIn('commit_id', $user->commits()->withTrashed()->select('id'))->delete(); $user->commits()->withTrashed()->update(['user_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); @@ -96,6 +94,16 @@ private function engagedCommitIds(User $user): array ]; } + /** + * The user's commits and every commit in the threads they started: all the erasure deletes or leaves as a tombstone. + */ + private function touchedCommits(User $user): CommitBuilder + { + return Commit::query()->withTrashed() + ->where(fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id) + ->orWhereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))); + } + /** * @return HasMany */ diff --git a/database/migrations/2026_10_26_200000_create_commit_topics_table.php b/database/migrations/2026_10_26_200000_create_commit_topics_table.php index f33649a92..c56cd34a6 100644 --- a/database/migrations/2026_10_26_200000_create_commit_topics_table.php +++ b/database/migrations/2026_10_26_200000_create_commit_topics_table.php @@ -17,7 +17,7 @@ public function up(): void { Schema::create('commit_topics', function (Blueprint $table): void { - $table->foreignUuid('commit_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('commit_id')->constrained(); $table->string('reference'); $table->string('topic_type'); $table->uuid('topic_id'); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 8948afcdc..bf227ecb2 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -45,12 +45,17 @@ $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(); Commit::factory()->for($user)->create()->delete(); - Commit::factory()->for($commit, 'parent')->create(); + $reply = Commit::factory()->for($commit, 'parent')->create(); $unrelated = Commit::factory()->create(); + $organization = Profile::factory()->organization()->create(); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); + resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => $organization->id]); + resolve(CreateCommitTopics::class)->handle($unrelated, ['laravel' => $organization->id]); resolve(DeleteUser::class)->handle($user); - expect(Commit::withTrashed()->pluck('id')->all())->toBe([$unrelated->id]); + expect(Commit::withTrashed()->pluck('id')->all())->toBe([$unrelated->id]) + ->and(DB::table('commit_topics')->pluck('commit_id')->all())->toBe([$unrelated->id]); }); it('keeps the quotes of others when erasing the quoted commits of a deleted user', function (): void { From 2e0eada6c43ae7f0c4881ce1e63291520f0533ed Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 17:59:20 +0100 Subject: [PATCH 7/9] fix: keep waiting for the GitHub rate limit instead of failing after three tries A release counted as a try, so a commit released by the rate limit three times failed even once GitHub answered again. The job now gives up after three exceptions, or a day. --- app/Jobs/LinkCommitTopics.php | 12 +++++-- tests/Unit/Jobs/LinkCommitTopicsTest.php | 42 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/app/Jobs/LinkCommitTopics.php b/app/Jobs/LinkCommitTopics.php index 9d551cd12..9aa05b53e 100644 --- a/app/Jobs/LinkCommitTopics.php +++ b/app/Jobs/LinkCommitTopics.php @@ -12,14 +12,15 @@ use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\DeleteWhenMissingModels; -use Illuminate\Queue\Attributes\Tries; +use Illuminate\Queue\Attributes\MaxExceptions; /** * Runs once a commit is approved, so only commits everyone can read spend GitHub's rate limit, and again once the rate - * limit resets when it cut the lookups short. + * limit resets when it cut the lookups short. Waiting for the reset doesn't count as a try: the job gives up after three + * failures, or a day. */ #[Backoff([60, 600])] -#[Tries(3)] +#[MaxExceptions(3)] #[DeleteWhenMissingModels] final class LinkCommitTopics implements ShouldQueue { @@ -49,6 +50,11 @@ public static function dispatchFor(Commit ...$commits): void ->each(fn (Commit $commit) => dispatch(new self($commit))->afterCommit()); } + public function retryUntil(): CarbonImmutable + { + return CarbonImmutable::now()->addDay(); + } + public function handle(UpdateCommitTopics $action): void { $retryAt = $action->handle($this->commit); diff --git a/tests/Unit/Jobs/LinkCommitTopicsTest.php b/tests/Unit/Jobs/LinkCommitTopicsTest.php index 47e52baef..4a4573fc8 100644 --- a/tests/Unit/Jobs/LinkCommitTopicsTest.php +++ b/tests/Unit/Jobs/LinkCommitTopicsTest.php @@ -7,7 +7,9 @@ use App\Models\Commit; use App\Models\Profile; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; it('links the organizations the commit mentions', function (): void { Http::fake(); @@ -44,3 +46,43 @@ $job->assertNotReleased(); }); + +it('waits for as many rate limit resets as it takes', function (): void { + Http::fake([ + 'api.github.com/users/laravel' => Http::response(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]), + ]); + + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + Queue::connection('database')->push(new LinkCommitTopics($commit)); + + foreach (range(1, 4) as $reset) { + Cache::put('github-organizations:paused', now()->addHour()->getTimestamp(), now()->addHour()); + $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful(); + $this->travel(1)->hour(); + } + + Http::assertNothingSent(); + + $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful(); + + expect($commit->refresh()->organization_mentions)->toBe(['laravel' => Profile::query()->where('handle', 'laravel')->value('id')]); + $this->assertDatabaseCount('jobs', 0); + $this->assertDatabaseCount('failed_jobs', 0); +}); + +it('gives up after three failures', function (): void { + Exceptions::fake(); + Http::fake(['api.github.com/users/*' => Http::response(status: 502)]); + + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + Queue::connection('database')->push(new LinkCommitTopics($commit)); + + foreach ([0, 60, 600] as $delay) { + $this->travel($delay)->seconds(); + $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful(); + } + + Http::assertSentCount(3); + $this->assertDatabaseCount('jobs', 0); + $this->assertDatabaseCount('failed_jobs', 1); +}); From 563df52dcec2a4eae5e828075b8f52052fe23ffe Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 18:03:26 +0100 Subject: [PATCH 8/9] refactor: store organization topics as organization rather than profile Users never become topics, so the type can say what the topic is. --- app/Actions/CreateCommitTopics.php | 2 +- app/Actions/DeleteOrganization.php | 2 +- app/Enums/TopicType.php | 6 +++--- app/Jobs/LinkCommitTopics.php | 2 +- app/Queries/CommitBuilder.php | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/Actions/CreateCommitTopics.php b/app/Actions/CreateCommitTopics.php index 59e4ae634..909dda3c2 100644 --- a/app/Actions/CreateCommitTopics.php +++ b/app/Actions/CreateCommitTopics.php @@ -21,7 +21,7 @@ public function handle(Commit $commit, array $organizations): void DB::table('commit_topics')->insertOrIgnore(array_map(fn (int|string $reference, string $id): array => [ 'commit_id' => $commit->id, 'reference' => (string) $reference, - 'topic_type' => TopicType::Profile->value, + 'topic_type' => TopicType::Organization->value, 'topic_id' => $id, ], array_keys($organizations), $organizations)); } diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index b73ff6592..dc315b770 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -18,7 +18,7 @@ public function handle(Profile $organization): void { DB::transaction(function () use ($organization): void { DB::table('commit_topics') - ->where('topic_type', TopicType::Profile->value) + ->where('topic_type', TopicType::Organization->value) ->where('topic_id', $organization->id) ->delete(); diff --git a/app/Enums/TopicType.php b/app/Enums/TopicType.php index 7f9b67e37..a96430013 100644 --- a/app/Enums/TopicType.php +++ b/app/Enums/TopicType.php @@ -5,10 +5,10 @@ namespace App\Enums; /** - * What a commit topic is, stored in `commit_topics.topic_type` next to its id. Only GitHub organizations so far, which - * are profiles. + * What a commit topic is, stored in `commit_topics.topic_type` next to its id. Only GitHub organizations so far, whose + * id is their profile's. */ enum TopicType: string { - case Profile = 'profile'; + case Organization = 'organization'; } diff --git a/app/Jobs/LinkCommitTopics.php b/app/Jobs/LinkCommitTopics.php index 9aa05b53e..c9dd9b16f 100644 --- a/app/Jobs/LinkCommitTopics.php +++ b/app/Jobs/LinkCommitTopics.php @@ -17,7 +17,7 @@ /** * Runs once a commit is approved, so only commits everyone can read spend GitHub's rate limit, and again once the rate * limit resets when it cut the lookups short. Waiting for the reset doesn't count as a try: the job gives up after three - * failures, or a day. + * failures, or a day after it was queued. */ #[Backoff([60, 600])] #[MaxExceptions(3)] diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 1c3ad8d03..7dbd9686d 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -32,9 +32,9 @@ final class CommitBuilder extends Builder ]; /** - * The organizations a commit mentions, by lowercase handle, from its topics of type TopicType::Profile. + * The organizations a commit mentions, by lowercase handle, from its topics of type TopicType::Organization. */ - private const string ORGANIZATION_MENTIONS = "(select json_object_agg(reference, topic_id) from commit_topics where commit_topics.commit_id = commits.id and topic_type = 'profile') as organization_mentions"; + private const string ORGANIZATION_MENTIONS = "(select json_object_agg(reference, topic_id) from commit_topics where commit_topics.commit_id = commits.id and topic_type = 'organization') as organization_mentions"; /** * Embeddings are only ever compared within the database, so commits are loaded without theirs rather than carry From 4b888b5c79bd1b9071cb2338109b0818aba6bb24 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 18:12:47 +0100 Subject: [PATCH 9/9] fix: link no topic to a commit erased while its organizations were looked up The lookup now reads the commit again under its row lock before linking, and DeleteUser locks the commits before deleting their links, so a link can neither survive an erasure nor make DeleteUser roll back. --- app/Actions/DeleteUser.php | 5 +++++ app/Actions/UpdateCommitTopics.php | 11 +++++++++-- tests/Unit/Actions/UpdateCommitTopicsTest.php | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 3bbeae5cb..8226ad8b0 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -48,6 +48,11 @@ public function handle(User $user): void ->lazyById(500) ->map(resolve(CommitTranslations::class)->key(...))->unique()->all(); + // Locked first, in statements of their own, so a topic lookup running meanwhile either links before this deletes its + // links or finds the commit erased. The threads are locked before their replies, so no reply joins them in between. + $user->commits()->withTrashed()->topLevel()->orderBy('id')->lockForUpdate()->pluck('id'); + $this->touchedCommits($user)->orderBy('id')->lockForUpdate()->pluck('id'); + DB::table('commit_topics')->whereIn('commit_id', $this->touchedCommits($user)->select('id'))->delete(); $user->commits()->withTrashed()->topLevel()->forceDelete(); CommitImage::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php index 56ec00fee..28c36019d 100644 --- a/app/Actions/UpdateCommitTopics.php +++ b/app/Actions/UpdateCommitTopics.php @@ -43,7 +43,8 @@ public function __construct( * Asks GitHub about the handles the commit mentions that are neither a user nor a known organization, and * refreshes the known organizations it mentions once their profile is a day old, then links the commit to them. * A handle that isn't an organization's isn't asked about again for a day, and an organization GitHub no longer - * has is deleted, with the links to it. Returns when to try again if the rate limit cut the lookups short. + * has is deleted, with the links to it. The links come from the commit as it is after the lookups, locked, so a + * commit erased in the meantime gets none. Returns when to try again if the rate limit cut the lookups short. */ public function handle(Commit $commit): ?CarbonImmutable { @@ -109,7 +110,13 @@ function () use ($organization): void { $this->updateOrCreateOrganization->handle($account); } - $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($commit->body, $commit->mentions ?? [])); + DB::transaction(function () use ($commit): void { + $commit = Commit::withTrashed()->lockForUpdate()->find($commit->id); + + if ($commit instanceof Commit) { + $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($commit->body, $commit->mentions ?? [])); + } + }); return $retryAt; } diff --git a/tests/Unit/Actions/UpdateCommitTopicsTest.php b/tests/Unit/Actions/UpdateCommitTopicsTest.php index b5d0cce66..929826a24 100644 --- a/tests/Unit/Actions/UpdateCommitTopicsTest.php +++ b/tests/Unit/Actions/UpdateCommitTopicsTest.php @@ -54,6 +54,21 @@ function gitHubOrganization(array $account = []): array ->and($commit->refresh()->organization_mentions)->toBe(['laravel' => $organization->id]); }); +it('links nothing to a commit erased while its handles were looked up', function (bool $gone): void { + $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); + + Http::fake(function () use ($commit, $gone) { + $gone ? $commit->forceDelete() : $commit->update(['body' => '', 'mentions' => null]); + + return Http::response(gitHubOrganization()); + }); + + resolve(UpdateCommitTopics::class)->handle($commit); + + expect(Profile::query()->where('handle', 'laravel')->exists())->toBeTrue(); + $this->assertDatabaseEmpty('commit_topics'); +})->with(['content erased' => false, 'deleted' => true]); + it('only asks GitHub about the handles that are neither a user nor a known organization', function (): void { Http::fake();