diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2d9ded45b..62cbb8c6e 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -555,7 +555,7 @@ Posts awaiting automated checks stay pending if those checks fail; a failed chec Before you first commit, reply, quote, or schedule a commit, the web composer shows the commit rules, and keeps showing them on every attempt until you send with "Got it, commit": no engagement bait, no low-effort posts, no AI-generated posts, no spam or self-promotion, and no harassment, hate, or private information, followed by a link to the terms. Your first submission must come from the web so you can accept these rules; terminal, GitHub Action, and app posting become available afterwards. -During a **timeout**, you can still read and react, but cannot commit, reply, quote, schedule, send remotes, or start, rename, add people to, or pin messages in orgs from any surface; each attempt says how long is left and why. Scheduled commits wait until the timeout ends. Like bans, timeouts belong to the GitHub account: deleting the /dev/null account and signing in again does not end a timeout or erase its history. +During a **timeout**, you can still read and react, but cannot commit, reply, quote, schedule, open issues, send remotes, or start, rename, add people to, or pin messages in orgs from any surface; each attempt says how long is left and why. Scheduled commits wait until the timeout ends. Like bans, timeouts belong to the GitHub account: deleting the /dev/null account and signing in again does not end a timeout or erase its history. A **ban** sends you to a page explaining why, where you can only sign out or delete your account; the app refuses everything but deleting the account, and the terminal refuses posting. A ban belongs to the GitHub account, so deleting the /dev/null account and signing in again does not lift it. Banning someone cancels their scheduled commits, so lifting the ban later does not publish them all at once. diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index 94f8ad1f5..166051c25 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -4,6 +4,7 @@ import type { KeyboardEvent } from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; import { store } from '@/actions/App/Http/Controllers/IssueController'; +import { showTimeout, timeoutFrom } from '@/components/timeout-dialog'; import { WordmarkText } from '@/components/wordmark-text'; import { collectEnvironment } from '@/lib/environment'; import type { IssueEnvironment } from '@/lib/environment'; @@ -96,11 +97,17 @@ export function useIssueCommand({ ); }, onHttpException: (response) => { - toast.error( - response.status === 429 - ? 'Issue limit reached: 10 a day. Try again tomorrow.' - : 'GitHub did not confirm the issue. Check upstream before retrying. Your draft is safe.', - ); + const timeout = timeoutFrom(response.status, response.data); + + if (timeout) { + showTimeout(timeout); + } else { + toast.error( + response.status === 429 + ? 'Issue limit reached: 10 a day. Try again tomorrow.' + : 'GitHub did not confirm the issue. Check upstream before retrying. Your draft is safe.', + ); + } }, onNetworkError: () => { toast.error( diff --git a/routes/web.php b/routes/web.php index ad2bb8301..495f06402 100644 --- a/routes/web.php +++ b/routes/web.php @@ -266,7 +266,7 @@ Route::middleware('auth')->group(function (): void { Route::post('issues', [IssueController::class, 'store']) - ->middleware('throttle:10,1') + ->middleware([PreventPostingInTimeout::class, 'throttle:10,1']) ->name('issue.store'); // Session... diff --git a/tests/Feature/Controllers/UserTimeoutControllerTest.php b/tests/Feature/Controllers/UserTimeoutControllerTest.php index 232c2050c..588b7fd78 100644 --- a/tests/Feature/Controllers/UserTimeoutControllerTest.php +++ b/tests/Feature/Controllers/UserTimeoutControllerTest.php @@ -197,6 +197,16 @@ ->and($message->fresh()?->pinned_at)->toBeNull(); }); +it('stops a user in timeout from opening issues', function (): void { + $user = User::factory()->create(); + UserTimeout::factory()->for($user)->create(); + + $this->actingAs($user) + ->postJson(route('issue.store'), ['command' => '/bug', 'body' => 'buy my course']) + ->assertForbidden() + ->assertJsonPath('timeout.reason', 'Spam or self-promotion'); +}); + it('tells the user how long is left of the timeout that ends last, rounded up', function (string $endsIn, string $remaining): void { $user = User::factory()->create(); UserTimeout::factory()->for($user)->create(['ends_at' => now()->addMinutes(30)]);