From 1ebf3b4c923fa3546ab11de42720eb808b834737 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 16:43:56 +0100 Subject: [PATCH] feat: let members schedule commits as their organization, canceled if they left it by then --- KNOWLEDGE_BASE.md | 4 +- app/Actions/CreateScheduledCommit.php | 4 +- app/Actions/DeleteOrganization.php | 15 +- .../UpdateScheduledCommitSubmittedAt.php | 25 +++- .../Api/ScheduledCommitController.php | 2 +- .../Controllers/ScheduledCommitController.php | 2 +- .../CreateApiScheduledCommitRequest.php | 14 ++ .../Requests/CreateScheduledCommitRequest.php | 14 ++ .../Resources/ScheduledCommitResource.php | 4 +- app/Models/ScheduledCommit.php | 14 +- app/Queries/ScheduledCommitsQuery.php | 2 +- ...d_author_id_to_scheduled_commits_table.php | 21 +++ resources/js/components/commit-composer.tsx | 37 ++--- .../js/pages/scheduled-commits/index.tsx | 23 ++- .../OrganizationScheduledCommitsTest.php | 135 ++++++++++++++++++ tests/Unit/ComposerDraft.test.tsx | 31 ++++ 16 files changed, 310 insertions(+), 37 deletions(-) create mode 100644 database/migrations/2026_11_01_160000_add_author_id_to_scheduled_commits_table.php create mode 100644 tests/Feature/OrganizationScheduledCommitsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index aad087540..c839e2215 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -120,7 +120,7 @@ A member of an organization can **Claim** its page, which lists them as its admi After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. -Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. Organizations can't schedule commits yet, and their own commits, replies and quotes don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`. +Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. They can schedule commits as the organization too, the same way: if they are no longer a member when it is due, it is canceled. The organization's own commits, replies and quotes don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`. Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. @@ -310,7 +310,7 @@ Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results]( ### Scheduled commits -Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**, in five-minute steps; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies and quotes cannot be scheduled. Scheduling counts against the 10-per-minute submission limit. The scheduled-commits page, linked from the scheduling control, is private to you: it lists queued posts, which you can cancel, and already submitted ones with their outcome. +Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**, in five-minute steps; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies and quotes cannot be scheduled. Scheduling counts against the 10-per-minute submission limit. The scheduled-commits page, linked from the scheduling control, is private to you: it lists queued posts, which you can cancel, and already submitted ones with their outcome. Members of an organization can schedule a commit as the organization, by picking it from their avatar first; the page shows which ones go out as an organization. The scheduler checks due posts every minute. Scheduling submits the post for the normal publishing and moderation flow; it is not a promise that an approved public post will appear at the exact second selected. To change a queued post, cancel it and schedule a replacement. diff --git a/app/Actions/CreateScheduledCommit.php b/app/Actions/CreateScheduledCommit.php index 6a78a3f70..6d00ed8d5 100644 --- a/app/Actions/CreateScheduledCommit.php +++ b/app/Actions/CreateScheduledCommit.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Dto\NewPoll; +use App\Models\Organization; use App\Models\ScheduledCommit; use App\Models\User; use App\Services\Urls; @@ -26,12 +27,13 @@ public function __construct(private CreateImageFiles $createImageFiles, private * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true): ScheduledCommit + public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null): ScheduledCommit { $body = mb_trim($body); return DB::transaction(fn (): ScheduledCommit => ScheduledCommit::query()->create([ 'user_id' => $user->id, + 'author_id' => $organization?->id, 'body' => $body, 'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null, 'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions), diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index d6a73375f..e9f4a53a0 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -8,11 +8,12 @@ use App\Models\Commit; use App\Models\Notification; use App\Models\Organization; +use App\Models\ScheduledCommit; use Illuminate\Support\Facades\DB; final readonly class DeleteOrganization { - public function __construct(private DeleteCommit $deleteCommit) + public function __construct(private DeleteCommit $deleteCommit, private DeleteStoredFiles $deleteStoredFiles) { // } @@ -20,11 +21,17 @@ public function __construct(private DeleteCommit $deleteCommit) /** * Deletes an organization GitHub no longer has, with its members and followers and what they heard about it, and * unlinks the commits that mention it, so none links to a page that is gone. Its own commits are deleted as their - * posters would, leaving tombstones for the replies under them, and lose their author with its profile. + * posters would, leaving tombstones for the replies under them, and lose their author with its profile. What its + * members scheduled as it goes too, images included. */ public function handle(Organization $organization): void { DB::transaction(function () use ($organization): void { + // Locked before the profile, in the order a scheduled commit being posted takes them: it holds its row, then + // waits on the profile, so taking them the other way round would deadlock. + $scheduled = ScheduledCommit::query()->where('author_id', $organization->id); + $scheduled->clone()->orderBy('id')->lockForUpdate()->pluck('id'); + // A commit posted for it meanwhile waits on the profile, and one restored meanwhile on its own row, then // fails or stays deleted, rather than slipping past the deletion and losing its author while still live. $organization->profile()->lockForUpdate()->first(); @@ -33,6 +40,10 @@ public function handle(Organization $organization): void ->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit)); $organization->profile->commits()->withTrashed()->update(['author_id' => null]); + $this->deleteStoredFiles->retain($scheduled->clone()->whereNotNull('images')->get(['images']) + ->flatMap(fn (ScheduledCommit $scheduled): array => array_column($scheduled->images ?? [], 'path'))->all()); + $scheduled->delete(); + DB::table('commit_topics') ->where('topic_type', TopicType::Organization->value) ->where('topic_id', $organization->id) diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php index fa6a2bf51..d3f0612e9 100644 --- a/app/Actions/UpdateScheduledCommitSubmittedAt.php +++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php @@ -5,24 +5,34 @@ namespace App\Actions; use App\Dto\NewPoll; +use App\Enums\Feature; use App\Enums\PollDuration; use App\Models\ScheduledCommit; +use App\Services\Features; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Facades\DB; final readonly class UpdateScheduledCommitSubmittedAt { - public function __construct(private CreateCommit $createCommit) + public function __construct(private CreateCommit $createCommit, private DeleteScheduledCommit $deleteScheduledCommit, private Features $features) { // } /** - * The author can commit it now, before its scheduled time. + * The author can commit it now, before its scheduled time. One scheduled as an organization its user is no longer + * a member of, or while posting as organizations is switched off, is canceled instead, as a ban cancels it. */ public function handle(string $id, bool $early = false): bool { return DB::transaction(function () use ($id, $early): bool { + $postable = fn (Builder $query): Builder => $query->whereNull('author_id') + ->when($this->features->enabled(Feature::OrganizationCommits), fn (Builder $query): Builder => $query->orWhereExists(fn (QueryBuilder $members): QueryBuilder => $members + ->from('organization_members') + ->whereColumn('organization_members.organization_id', 'scheduled_commits.author_id') + ->whereColumn('organization_members.user_id', 'scheduled_commits.user_id'))); + // Claim with a conditional write before reading. // Creation and the claim roll back together if either operation fails. // Moderation is checked in the claim too, since a timeout or ban can land after the batch was selected... @@ -31,16 +41,23 @@ public function handle(string $id, bool $early = false): bool ->unless($early, fn (Builder $query): Builder => $query->where('scheduled_at', '<=', now())) ->whereDoesntHave('user.timeouts', fn (Builder $query): Builder => $query->where('ends_at', '>', now())) ->whereDoesntHave('user.ban') + ->where($postable) ->update(['submitted_at' => now()]); if ($claimed === 0) { + $lost = ScheduledCommit::query()->with('user')->whereKey($id)->whereNot($postable)->first(); + + if ($lost instanceof ScheduledCommit) { + $this->deleteScheduledCommit->handle($lost->user, $lost); + } + return false; } - $scheduled = ScheduledCommit::query()->with('user')->findOrFail($id); + $scheduled = ScheduledCommit::query()->with(['user', 'author'])->findOrFail($id); assert(is_string($scheduled->body)); $poll = $scheduled->poll === null ? null : new NewPoll($scheduled->poll['options'], PollDuration::from($scheduled->poll['duration'])); - $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll); + $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization); $commit->images()->createMany($scheduled->images ?? []); $scheduled->forceFill(['commit_id' => $commit->id, 'body' => null, 'poll' => null, 'images' => null, 'link_preview_url' => null])->save(); diff --git a/app/Http/Controllers/Api/ScheduledCommitController.php b/app/Http/Controllers/Api/ScheduledCommitController.php index d04feffdc..ec9b1fe5d 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -33,7 +33,7 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule */ public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse { - $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview()); + $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); return ScheduledCommitResource::make($scheduled) ->response() diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 5646854a6..84c50a53d 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -32,7 +32,7 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { - $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview()); + $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); Inertia::flash('scheduled', true); Inertia::flash('toast', ['type' => 'success', 'message' => 'Commit scheduled. Only you can see it until it is submitted.']); diff --git a/app/Http/Requests/CreateApiScheduledCommitRequest.php b/app/Http/Requests/CreateApiScheduledCommitRequest.php index ba1aeba7f..9775b87c6 100644 --- a/app/Http/Requests/CreateApiScheduledCommitRequest.php +++ b/app/Http/Requests/CreateApiScheduledCommitRequest.php @@ -6,7 +6,9 @@ use App\Dto\NewPoll; use App\Enums\PollDuration; +use App\Models\Organization; use App\Models\User; +use App\Rules\OrganizationToPostAs; use App\Rules\ScheduledCommitLimit; use App\Services\Urls; use Illuminate\Contracts\Validation\ValidationRule; @@ -46,6 +48,7 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), + 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], 'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)], 'parent_id' => ['prohibited'], 'quoted_commit_id' => ['prohibited'], @@ -86,6 +89,17 @@ public function linkPreview(): bool|string return $this->filled('link_preview_url') ? $this->string('link_preview_url')->value() : false; } + /** + * The organization the commit goes out as, or null when it goes out as the user. + */ + public function organization(): ?Organization + { + $user = $this->user(); + assert($user instanceof User); + + return OrganizationToPostAs::of($user, $this->input('author')); + } + public function poll(): ?NewPoll { if (! $this->filled('poll')) { diff --git a/app/Http/Requests/CreateScheduledCommitRequest.php b/app/Http/Requests/CreateScheduledCommitRequest.php index 5e38c5acc..d7fffb293 100644 --- a/app/Http/Requests/CreateScheduledCommitRequest.php +++ b/app/Http/Requests/CreateScheduledCommitRequest.php @@ -6,7 +6,9 @@ use App\Dto\NewPoll; use App\Enums\PollDuration; +use App\Models\Organization; use App\Models\User; +use App\Rules\OrganizationToPostAs; use App\Rules\ScheduledCommitLimit; use App\Services\Urls; use Illuminate\Contracts\Validation\ValidationRule; @@ -34,6 +36,7 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), + 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], 'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)], 'parent_id' => ['prohibited'], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], @@ -73,6 +76,17 @@ public function linkPreview(): bool|string return $this->filled('link_preview_url') ? $this->string('link_preview_url')->value() : false; } + /** + * The organization the commit goes out as, or null when it goes out as the user. + */ + public function organization(): ?Organization + { + $user = $this->user(); + assert($user instanceof User); + + return OrganizationToPostAs::of($user, $this->input('author')); + } + public function poll(): ?NewPoll { if (! $this->filled('poll')) { diff --git a/app/Http/Resources/ScheduledCommitResource.php b/app/Http/Resources/ScheduledCommitResource.php index d6c02c426..c8f1346be 100644 --- a/app/Http/Resources/ScheduledCommitResource.php +++ b/app/Http/Resources/ScheduledCommitResource.php @@ -11,7 +11,8 @@ /** * A commit the user queued, as the web's Scheduled page shows it. Once submitted, its body lives on the commit and - * only the status says what became of it: scheduled, the commit's moderation status, or deleted. + * only the status says what became of it: scheduled, the commit's moderation status, or deleted. Its author is the + * organization it goes out as, or null when it goes out as the user. * * @mixin ScheduledCommit */ @@ -24,6 +25,7 @@ public function toArray(Request $request): array { return [ 'id' => $this->id, + 'author' => $this->author === null ? null : ProfileResource::make($this->author)->resolve($request), ...($this->submitted_at === null && $this->body !== null ? ['body' => $this->body, ...resolve(CommitBodies::class)->segments($this->body)] : ['body' => null, 'body_segments' => [], 'preview_segments' => null]), diff --git a/app/Models/ScheduledCommit.php b/app/Models/ScheduledCommit.php index d5c0c6bf6..5bd7170ce 100644 --- a/app/Models/ScheduledCommit.php +++ b/app/Models/ScheduledCommit.php @@ -15,6 +15,7 @@ /** * @property-read string $id * @property-read string $user_id + * @property-read string|null $author_id * @property-read string|null $commit_id * @property-read string|null $body * @property-read array{options: list, duration: int}|null $poll @@ -24,9 +25,10 @@ * @property-read CarbonInterface|null $submitted_at * @property-read CarbonInterface|null $canceled_at * @property-read User $user + * @property-read Profile|null $author * @property-read Commit|null $commit */ -#[Fillable(['user_id', 'body', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] +#[Fillable(['user_id', 'author_id', 'body', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] final class ScheduledCommit extends Model { /** @use HasFactory */ @@ -52,6 +54,16 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + /** + * The organization it goes out as, or null when it goes out as its user. + * + * @return BelongsTo + */ + public function author(): BelongsTo + { + return $this->belongsTo(Profile::class)->with(['user', 'organization']); + } + /** @return BelongsTo */ public function commit(): BelongsTo { diff --git a/app/Queries/ScheduledCommitsQuery.php b/app/Queries/ScheduledCommitsQuery.php index 90da01037..51a76c22f 100644 --- a/app/Queries/ScheduledCommitsQuery.php +++ b/app/Queries/ScheduledCommitsQuery.php @@ -19,7 +19,7 @@ public function builder(User $user): Builder { return ScheduledCommit::query()->where('user_id', $user->id) - ->whereNull('canceled_at')->with('commit') + ->whereNull('canceled_at')->with(['commit', 'author']) ->orderByRaw('submitted_at IS NOT NULL')->orderBy('scheduled_at')->orderBy('id'); } } diff --git a/database/migrations/2026_11_01_160000_add_author_id_to_scheduled_commits_table.php b/database/migrations/2026_11_01_160000_add_author_id_to_scheduled_commits_table.php new file mode 100644 index 000000000..5baba8595 --- /dev/null +++ b/database/migrations/2026_11_01_160000_add_author_id_to_scheduled_commits_table.php @@ -0,0 +1,21 @@ +foreignUuid('author_id')->nullable()->index()->constrained('profiles'); + }); + } +}; diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a78973f89..1208f3ddc 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -185,8 +185,6 @@ function CommitComposerBody({ return () => observer.disconnect(); }, [isInDialog]); - // Scheduled commits are always one's own, for now. - const postingAs = isScheduling ? null : author; const ownNode = isInDialog ? ( ) : ( @@ -217,30 +215,26 @@ function CommitComposerBody({ )} - {author === null ? ( - - ) : ( - - )} - - ) + + {author === null ? ( + + ) : ( + + )} + } /> @@ -725,8 +719,7 @@ export function CommitComposer({ transform={(data) => ({ ...data, commit_rules_accepted: commitRules.hasAccepted(), - ...(author !== null && - !isScheduling && { author: author.handle }), + ...(author !== null && { author: author.handle }), ...(hasImages && { images: attachments.images, image_descriptions: attachments.descriptions, diff --git a/resources/js/pages/scheduled-commits/index.tsx b/resources/js/pages/scheduled-commits/index.tsx index f6a9663a2..10ed6d869 100644 --- a/resources/js/pages/scheduled-commits/index.tsx +++ b/resources/js/pages/scheduled-commits/index.tsx @@ -11,6 +11,7 @@ import LogController from '@/actions/App/Http/Controllers/LogController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; import ScheduledCommitSubmissionController from '@/actions/App/Http/Controllers/ScheduledCommitSubmissionController'; import { FoldedCommitBody } from '@/components/commit-body'; +import { OrganizationAvatar } from '@/components/organization-avatar'; import { StableLabel } from '@/components/stable-label'; import { Button } from '@/components/ui/button'; import { @@ -23,10 +24,12 @@ import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; import { emojify } from '@/lib/emoji'; import { formatClockDateTime, readerClock } from '@/lib/utils'; -import type { BodySegment } from '@/types'; +import type { BodySegment, OrganizationAuthor } from '@/types'; type ScheduledCommit = { id: string; + // The organization it goes out as, or null when it goes out as you. + author: OrganizationAuthor | null; body: string | null; body_segments: BodySegment[]; preview_segments: BodySegment[] | null; @@ -214,6 +217,24 @@ export default function Index({ > {when} + {scheduled.author && ( +

+ + + As @ + {scheduled.author.handle} + +

+ )} {scheduled.status === 'scheduled' && (
diff --git a/tests/Feature/OrganizationScheduledCommitsTest.php b/tests/Feature/OrganizationScheduledCommitsTest.php new file mode 100644 index 000000000..4391ec537 --- /dev/null +++ b/tests/Feature/OrganizationScheduledCommitsTest.php @@ -0,0 +1,135 @@ +travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); + resolve(Features::class)->update(Feature::OrganizationCommits, true); + $this->organization = Organization::factory()->create(['handle' => 'Laravel']); + $this->member = User::factory()->create(['commit_rules_accepted_at' => now()]); + $this->organization->members()->attach($this->member->id, ['role' => OrganizationRole::Member]); +}); + +it('lets a member schedule a commit as the organization, which goes out as it when due', function (): void { + $this->actingAs($this->member) + ->post(route('scheduled-commit.store'), ['body' => 'Laravel 13.3 ships today', 'author' => 'laravel', 'scheduled_at' => now()->addHour()->toIso8601String()]) + ->assertSessionHasNoErrors(); + + $this->get(route('scheduled-commit.index')) + ->assertInertia(fn ($page) => $page + ->where('scheduledCommits.data.0.author.type', 'organization') + ->where('scheduledCommits.data.0.author.handle', 'Laravel')); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + $commit = Commit::query()->sole(); + + expect($commit->author_id)->toBe($this->organization->id) + ->and($commit->user_id)->toBe($this->member->id) + ->and(ScheduledCommit::query()->sole()->commit_id)->toBe($commit->id); +}); + +it('lets apps schedule a commit as the organization through the API, and says who it goes out as', function (): void { + $keyPair = apiKeyPairFor($this->member); + + signedApiRequest($keyPair, 'POST', route('api-scheduled-commit.store'), ['body' => 'Laravel 13.3 ships today', 'author' => 'laravel', 'scheduled_at' => now()->addHour()->toIso8601String()]) + ->assertCreated() + ->assertJsonPath('data.author.type', 'organization') + ->assertJsonPath('data.author.id', $this->organization->id) + ->assertJsonPath('data.author.handle', 'Laravel'); + signedApiRequest($keyPair, 'POST', route('api-scheduled-commit.store'), ['body' => 'My own release notes', 'scheduled_at' => now()->addHours(2)->toIso8601String()]) + ->assertCreated() + ->assertJsonPath('data.author', null); + + expect(ScheduledCommit::query()->oldest('scheduled_at')->pluck('author_id')->all())->toBe([$this->organization->id, null]); +}); + +it('lets no one schedule as an organization they are not a member of, or before administrators switch it on', function (): void { + $this->actingAs(User::factory()->create(['commit_rules_accepted_at' => now()])) + ->post(route('scheduled-commit.store'), ['body' => 'Not mine to post', 'author' => 'laravel', 'scheduled_at' => now()->addHour()->toIso8601String()]) + ->assertSessionHasErrors(['author' => 'You can only post as an organization you are a member of.']); + + resolve(Features::class)->update(Feature::OrganizationCommits, false); + + $this->actingAs($this->member) + ->post(route('scheduled-commit.store'), ['body' => 'Not yet', 'author' => 'laravel', 'scheduled_at' => now()->addHour()->toIso8601String()]) + ->assertSessionHasErrors(['author' => "Posting as an organization isn't available yet."]); + signedApiRequest(apiKeyPairFor($this->member), 'POST', route('api-scheduled-commit.store'), ['body' => 'Not yet', 'author' => 'laravel', 'scheduled_at' => now()->addHour()->toIso8601String()]) + ->assertUnprocessable() + ->assertJsonValidationErrors('author'); + + expect(ScheduledCommit::query()->exists())->toBeFalse(); +}); + +it('cancels what a member scheduled as the organization once they are no longer in it, images included', function (): void { + Storage::fake(); + $this->actingAs($this->member)->post(route('scheduled-commit.store'), [ + 'body' => 'Laravel 13.3 ships today', + 'author' => 'laravel', + 'scheduled_at' => now()->addHour()->toIso8601String(), + 'images' => [UploadedFile::fake()->image('release.png', 300, 200)], + ])->assertSessionHasNoErrors(); + $paths = array_column(ScheduledCommit::query()->sole()->images ?? [], 'path'); + $own = ScheduledCommit::factory()->for($this->member)->create(['scheduled_at' => now()->addHour()]); + resolve(DeleteOrganizationMember::class)->handle($this->organization, $this->member); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + $canceled = ScheduledCommit::query()->whereKeyNot($own->id)->sole(); + + expect($canceled->canceled_at)->not->toBeNull() + ->and($canceled->body)->toBeNull() + ->and($canceled->images)->toBeNull() + ->and(Commit::query()->sole()->author_id)->toBe($this->member->id); + Storage::disk()->assertMissing($paths); +}); + +it('cancels a commit scheduled as the organization that a former member tries to commit early', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + resolve(DeleteOrganizationMember::class)->handle($this->organization, $this->member); + + $this->actingAs($this->member) + ->post(route('scheduled-commit-submission.store', $scheduled)) + ->assertInertiaFlash('toast.message', 'This commit was already submitted or canceled. Refresh to see its status.'); + + expect($scheduled->refresh()->canceled_at)->not->toBeNull() + ->and(Commit::query()->exists())->toBeFalse(); +}); + +it('cancels what was scheduled as an organization once posting as one is switched off', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'scheduled_at' => now()->addHour()]); + resolve(Features::class)->update(Feature::OrganizationCommits, false); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect($scheduled->refresh()->canceled_at)->not->toBeNull() + ->and(Commit::query()->exists())->toBeFalse(); +}); + +it('deletes what was scheduled as an organization GitHub no longer has, images included', function (): void { + Storage::fake(); + Storage::disk()->put('scheduled/release.png', 'image'); + ScheduledCommit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'images' => [['path' => 'scheduled/release.png', 'width' => 300, 'height' => 200, 'description' => null, 'position' => 0]]]); + ScheduledCommit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $own = ScheduledCommit::factory()->for($this->member)->create(); + + resolve(DeleteOrganization::class)->handle($this->organization); + + expect(ScheduledCommit::query()->sole()->id)->toBe($own->id); + Storage::disk()->assertMissing('scheduled/release.png'); +}); diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index f786609a5..40f0d80f5 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -88,6 +88,7 @@ const { LinkCardPicker } = await import('../../resources/js/components/link-card-picker'); const { PollEditor } = await import('../../resources/js/components/poll-editor'); +const { GraphRail } = await import('../../resources/js/components/graph-rail'); type Element = ReactElement>; @@ -697,6 +698,36 @@ test('commands are not offered while the draft has images, a poll or a schedule' ); }); +test('schedules as the organization picked from the avatar', () => { + const organization = { + type: 'organization', + id: 'laravel', + handle: 'laravel', + lane: null, + }; + const node = () => + render().fields.find((element) => element.type === GraphRail)!.props + .node as Element; + const find = (key: string, value: unknown) => + render().fields.find((element) => element.props[key] === value)!; + + (find('aria-controls', 'body-schedule').props.onClick as () => void)(); + (node().props.onChange as (author: unknown) => void)(organization); + (find('id', 'body-scheduled-at').props.onChange as (value: string) => void)( + '2026-10-17T10:30', + ); + + assert.equal(node().props.author, organization); + assert.deepEqual(render().form.props.transform({ body: draft.body }), { + body: draft.body, + commit_rules_accepted: true, + author: 'laravel', + scheduled_at: new Date('2026-10-17T10:30') + .toISOString() + .replace(/\.\d{3}Z$/, '+00:00'), + }); +}); + test('turning scheduling off clears the chosen time and commits now', () => { const find = (key: string, value: unknown) => render().fields.find((element) => element.props[key] === value);