From 59cae245e9e7903a860905df91bcb27eaa2c3bd7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 18:55:56 +0100 Subject: [PATCH 1/3] feat: open /feature and /bug issues from the API --- KNOWLEDGE_BASE.md | 4 +- README.md | 7 +- app/Enums/ApiErrorCode.php | 2 + app/Http/Controllers/Api/IssueController.php | 35 +++++ app/Http/Controllers/IssueController.php | 14 +- app/Http/Middleware/ThrottleIssues.php | 50 +++++++ app/Http/Requests/CreateApiIssueRequest.php | 38 ++++++ routes/api.php | 7 + routes/web.php | 3 +- .../Controllers/Api/IssueControllerTest.php | 128 ++++++++++++++++++ .../Controllers/IssueControllerTest.php | 13 -- tests/Pest.php | 18 +++ 12 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 app/Http/Controllers/Api/IssueController.php create mode 100644 app/Http/Middleware/ThrottleIssues.php create mode 100644 app/Http/Requests/CreateApiIssueRequest.php create mode 100644 tests/Feature/Controllers/Api/IssueControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index dd354e0a6..fab4fe4f0 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -439,9 +439,9 @@ Start a fresh commit draft with **`/bug `** or **`/feature `** to switch it into Empty issue drafts show a short Markdown template: steps and expected versus actual behavior for bugs, or the problem and proposal for features. The first top-level `# Heading` becomes the issue title and is removed from the body; the remaining Markdown is preserved. Without a heading, the first line is used as the title and the full draft remains in the body. -Successful issue creation is limited to **five issues per hour per user**. If GitHub or the connection fails, the draft stays available. Check upstream before retrying an uncertain submission to avoid duplicates. Use a commit's Report control instead when reporting abusive content. +Successful issue creation is limited to **five issues per hour per user**, on the web and through the signed API together. If GitHub or the connection fails, the draft stays available. Check upstream before retrying an uncertain submission to avoid duplicates. Use a commit's Report control instead when reporting abusive content. -Sources: [issue command UI](resources/js/components/issue-command.tsx), [issue validation](app/Http/Requests/CreateIssueRequest.php), [issue controller](app/Http/Controllers/IssueController.php). +Sources: [issue command UI](resources/js/components/issue-command.tsx), [issue validation](app/Http/Requests/CreateIssueRequest.php), [issue controller](app/Http/Controllers/IssueController.php), [issue limit](app/Http/Middleware/ThrottleIssues.php). ### Exporting your data diff --git a/README.md b/README.md index fcc948e5b..05bce081b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Every profile also has an Atom feed of its latest 50 top-level commits at `/@{us ## Signed requests -The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting and pastéis only take signed requests. Signed requests are limited to 120 a minute per user. +The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting, pastéis and opening issues only take signed requests. Signed requests are limited to 120 a minute per user. Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. @@ -95,6 +95,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. +`POST /api/v1/issues` opens a public issue on `nunomaduro/dev-null.sh`, as the web's `/feature` and `/bug` commands do. It takes a `command`, `/feature` or `/bug`, and a `body` of 3 to 4,000 characters, with blank lines collapsed first, and answers `201` with the issue's `number` and `url`. The GitHub App opens it, crediting the user, labelled `enhancement` or `bug`. Each user can open 5 issues an hour, on the web and in the app together, and only issues that were opened count. When GitHub rejects the issue or doesn't respond, it's a `502` with `github_issue_failed`, and the message says to check upstream first if GitHub may have opened it anyway. + `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. `DELETE /api/v1/me` deletes the signed-in user's account the way Delete account does on the web, with their commits and every device they approved. It answers `204`, and from then on the key gets `device_not_approved`. @@ -139,7 +141,8 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `403` | `own_commit` | The user tried to report their own commit. | | `404` | `commit_unavailable` | The commit to report doesn't exist, was deleted, isn't published, or is across a block. | | `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. | -| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 60 pastéis a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | +| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 5 opened issues an hour, 60 pastéis a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | +| `502` | `github_issue_failed` | `POST /api/v1/issues` only: GitHub rejected the issue or didn't respond. When it didn't respond, it may have opened the issue anyway. Neither counts against the limit. | | `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. | Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for a commit that doesn't exist, except when reporting it. Unsigned requests to the public endpoints get the same errors as before, without codes. diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index 4c1354e81..dc0251af3 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -20,6 +20,7 @@ enum ApiErrorCode: string case PasteisExhausted = 'pasteis_exhausted'; case RateLimited = 'rate_limited'; case GitHubSyncFailed = 'github_sync_failed'; + case GitHubIssueFailed = 'github_issue_failed'; public function status(): int { @@ -30,6 +31,7 @@ public function status(): int self::CommitUnavailable => 404, self::PasteisExhausted => 422, self::RateLimited => 429, + self::GitHubIssueFailed => 502, self::GitHubSyncFailed => 503, }; } diff --git a/app/Http/Controllers/Api/IssueController.php b/app/Http/Controllers/Api/IssueController.php new file mode 100644 index 000000000..417404d0d --- /dev/null +++ b/app/Http/Controllers/Api/IssueController.php @@ -0,0 +1,35 @@ +handle($user, $request->string('command')->value(), $request->string('body')->value()); + } catch (ConnectionException) { + return new ApiException(ApiErrorCode::GitHubIssueFailed, __('GitHub did not respond. Check the upstream issues before trying again. Your draft is safe.'))->render(); + } catch (RequestException) { + return new ApiException(ApiErrorCode::GitHubIssueFailed, __('GitHub did not create the issue. Your draft is safe.'))->render(); + } + + return response()->json($issue, 201); + } +} diff --git a/app/Http/Controllers/IssueController.php b/app/Http/Controllers/IssueController.php index 04ee3d47a..dadcc1793 100644 --- a/app/Http/Controllers/IssueController.php +++ b/app/Http/Controllers/IssueController.php @@ -11,23 +11,15 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\RateLimiter; final readonly class IssueController { - private const int MaxIssuesPerHour = 5; - /** - * Only issues that were opened count towards the hourly limit, so a rejected draft or a GitHub outage costs nothing. + * Only issues that were opened count towards the hourly limit, so a rejected draft or a GitHub outage costs nothing + * (see ThrottleIssues). */ public function store(CreateIssueRequest $request, #[CurrentUser] User $user, CreateIssue $action): JsonResponse { - $key = 'issues:'.$user->id; - - if (RateLimiter::tooManyAttempts($key, self::MaxIssuesPerHour)) { - return response()->json(['message' => 'Issue limit reached. Try again in an hour.'], 429); - } - try { $issue = $action->handle( $user, @@ -45,8 +37,6 @@ public function store(CreateIssueRequest $request, #[CurrentUser] User $user, Cr ], 502); } - RateLimiter::hit($key, 3600); - return response()->json($issue, 201); } } diff --git a/app/Http/Middleware/ThrottleIssues.php b/app/Http/Middleware/ThrottleIssues.php new file mode 100644 index 000000000..e9dde971c --- /dev/null +++ b/app/Http/Middleware/ThrottleIssues.php @@ -0,0 +1,50 @@ +user(); + assert($user instanceof User); + + $key = "issues:{$user->id}"; + + if (RateLimiter::tooManyAttempts($key, self::PER_HOUR)) { + $message = 'Issue limit reached. Try again in an hour.'; + $headers = ['Retry-After' => RateLimiter::availableIn($key)]; + + // The mobile app reads JSON with a code... + return $request->routeIs('api-issue.store') + ? new ApiException(ApiErrorCode::RateLimited, $message, $headers)->render() + : response()->json(['message' => $message], 429, $headers); + } + + $response = $next($request); + + if ($response->getStatusCode() === 201) { + RateLimiter::hit($key, 3600); + } + + return $response; + } +} diff --git a/app/Http/Requests/CreateApiIssueRequest.php b/app/Http/Requests/CreateApiIssueRequest.php new file mode 100644 index 000000000..1b298c270 --- /dev/null +++ b/app/Http/Requests/CreateApiIssueRequest.php @@ -0,0 +1,38 @@ +> + */ + public function rules(): array + { + return [ + 'command' => ['required', Rule::in(['/feature', '/bug'])], + 'body' => CreateCommitRequest::BODY_RULES, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 63a0249e2..e31833e31 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Api\CommitStarController; use App\Http\Controllers\Api\CurrentUserController; use App\Http\Controllers\Api\DeviceController; +use App\Http\Controllers\Api\IssueController; use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\NotificationGroupController; @@ -29,6 +30,7 @@ use App\Http\Middleware\AuthenticateSignedRequest; use App\Http\Middleware\AuthenticateSshSignature; use App\Http\Middleware\ThrottleCommits; +use App\Http\Middleware\ThrottleIssues; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Route; @@ -138,6 +140,11 @@ Route::post('users/{user:username}/block', [UserBlockController::class, 'store'])->name('api-user-block.store'); Route::delete('users/{user:username}/block', [UserBlockController::class, 'destroy'])->name('api-user-block.destroy'); + // Issue: opened upstream by the GitHub App, as the web's /feature and /bug commands do... + Route::post('issues', [IssueController::class, 'store']) + ->middleware(['throttle:10,1', ThrottleIssues::class]) + ->name('api-issue.store'); + // Notifications... Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); Route::get('notifications/{notification}/group', [NotificationGroupController::class, 'show'])->name('api-notification-group.show'); diff --git a/routes/web.php b/routes/web.php index 2b873f693..12d3621a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -95,6 +95,7 @@ use App\Http\Controllers\UserTimezoneController; use App\Http\Middleware\RememberIntendedUrl; use App\Http\Middleware\ThrottleCommits; +use App\Http\Middleware\ThrottleIssues; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -220,7 +221,7 @@ Route::middleware('auth')->group(function (): void { Route::post('issues', [IssueController::class, 'store']) - ->middleware('throttle:10,1') + ->middleware(['throttle:10,1', ThrottleIssues::class]) ->name('issue.store'); // Session... diff --git a/tests/Feature/Controllers/Api/IssueControllerTest.php b/tests/Feature/Controllers/Api/IssueControllerTest.php new file mode 100644 index 000000000..ad4474e0e --- /dev/null +++ b/tests/Feature/Controllers/Api/IssueControllerTest.php @@ -0,0 +1,128 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + assert($key !== false); + openssl_pkey_export($key, $privateKey); + + config(['services.github_app' => ['id' => '12345', 'private_key' => str_replace("\n", '\n', $privateKey)]]); + + $this->user = User::factory()->create(['username' => 'taylor']); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('opens the issue as the app, crediting the reporter, with the label of the command, as the web does', function (string $command, string $label): void { + fakeGitHubApp(); + $body = "Search loses its filters\n\nSteps: select PHP, then navigate back."; + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => $command, 'body' => $body]) + ->assertCreated() + ->assertExactJson(['number' => 123, 'url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123']); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/nunomaduro/dev-null.sh/issues' + && $request->hasHeader('Authorization', 'Bearer installation-token') + && $request['title'] === 'Search loses its filters' + && $request['body'] === $body."\n\n---\n\nReported by @taylor via [/dev/null](".route('user.show', $this->user).').' + && $request['labels'] === [$label]); + $this->assertDatabaseCount('commits', 0); +})->with([ + 'feature' => ['/feature', 'enhancement'], + 'bug' => ['/bug', 'bug'], +]); + +it('collapses blank lines before measuring and sending the body', function (): void { + fakeGitHubApp(); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/feature', 'body' => "Dark mode\n\n\n\nplease"]) + ->assertCreated(); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/issues') + && str_starts_with((string) $request['body'], "Dark mode\n\nplease\n\n---")); +}); + +it('keeps the draft when GitHub rejects the issue or does not respond, with a code', function (Closure $response, string $message): void { + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/installation' => Http::response(['id' => 99]), + 'api.github.com/app/installations/99/access_tokens' => Http::response(['token' => 'installation-token'], 201), + 'api.github.com/repos/nunomaduro/dev-null.sh/issues' => $response, + ]); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.']) + ->assertStatus(502) + ->assertExactJson(['message' => $message, 'code' => 'github_issue_failed']); +})->with([ + 'rejected' => [fn () => Http::response(['message' => 'Validation Failed'], 422), 'GitHub did not create the issue. Your draft is safe.'], + 'unavailable' => [fn () => throw new ConnectionException('timeout'), 'GitHub did not respond. Check the upstream issues before trying again. Your draft is safe.'], +]); + +it('validates the command and the body as the web does', function (array $payload, string $field): void { + Http::fake(); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), $payload) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors($field); + + Http::assertNothingSent(); +})->with([ + 'unknown command' => [['command' => '/deploy', 'body' => 'Ship it.'], 'command'], + 'no command' => [['body' => 'Ship it.'], 'command'], + 'missing body' => [['command' => '/bug'], 'body'], + 'blank lines only' => [['command' => '/bug', 'body' => "a\n\n\n\n"], 'body'], + 'too long' => [['command' => '/bug', 'body' => str_repeat('a', 4001)], 'body'], +]); + +it('shares the web limit of five opened issues an hour, answering with a code', function (): void { + fakeGitHubApp(); + + foreach (range(1, 3) as $attempt) { + $this->actingAs($this->user)->postJson(route('issue.store'), ['command' => '/bug', 'body' => "Broken {$attempt}."])->assertCreated(); + } + + foreach (range(4, 5) as $attempt) { + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => "Broken {$attempt}."])->assertCreated(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken again.']) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 3600) + ->assertExactJson(['message' => 'Issue limit reached. Try again in an hour.', 'code' => 'rate_limited']); + + $this->travel(1)->hours(); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken later.'])->assertCreated(); +}); + +it('does not count rejected drafts or failed attempts towards the limit', function (): void { + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/installation' => Http::response(['id' => 99]), + 'api.github.com/app/installations/99/access_tokens' => Http::response(['token' => 'installation-token'], 201), + 'api.github.com/repos/nunomaduro/dev-null.sh/issues' => Http::sequence() + ->push([], 500)->push([], 500)->push([], 500) + ->push([], 500)->push([], 500)->push([], 500) + ->push(['number' => 1, 'html_url' => 'https://github.com/nunomaduro/dev-null.sh/issues/1'], 201), + ]); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/deploy', 'body' => 'Nope.'])->assertUnprocessable(); + + foreach (range(1, 6) as $attempt) { + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.'])->assertStatus(502); + $this->travel(11)->seconds(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.'])->assertCreated(); +}); + +it('rejects requests that are not signed', function (): void { + Http::fake(); + + $this->actingAs($this->user)->postJson(route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.'])->assertUnauthorized(); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Controllers/IssueControllerTest.php b/tests/Feature/Controllers/IssueControllerTest.php index 3e9ef9f0c..a8aac7099 100644 --- a/tests/Feature/Controllers/IssueControllerTest.php +++ b/tests/Feature/Controllers/IssueControllerTest.php @@ -19,19 +19,6 @@ config(['services.github_app' => ['id' => '12345', 'private_key' => str_replace("\n", '\n', $privateKey)]]); }); -function fakeGitHubApp(array $issue = []): void -{ - Http::fake([ - 'api.github.com/repos/nunomaduro/dev-null.sh/installation' => Http::response(['id' => 99]), - 'api.github.com/app/installations/99/access_tokens' => Http::response(['token' => 'installation-token'], 201), - 'api.github.com/repos/nunomaduro/dev-null.sh/issues' => Http::response([ - 'number' => 123, - 'html_url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123', - ...$issue, - ], 201), - ]); -} - it('opens the issue as the app, crediting the reporter, with the label of the command', function (string $command, string $label): void { fakeGitHubApp(); $user = User::factory()->create(['username' => 'taylor']); diff --git a/tests/Pest.php b/tests/Pest.php index 6e10bd963..cd57800ae 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -176,6 +176,24 @@ function fakeGitHubSponsorsListing(bool $hasSponsorsListing = false): void ]); } +/** + * The GitHub App opening issue #123 upstream, as the /feature and /bug commands do. + * + * @param array $issue + */ +function fakeGitHubApp(array $issue = []): void +{ + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/installation' => Http::response(['id' => 99]), + 'api.github.com/app/installations/99/access_tokens' => Http::response(['token' => 'installation-token'], 201), + 'api.github.com/repos/nunomaduro/dev-null.sh/issues' => Http::response([ + 'number' => 123, + 'html_url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123', + ...$issue, + ], 201), + ]); +} + /** * @param list $imageHashes * @return array{payload: string, signature: string} From f7a20dd7be4161413353826b89088034ecea9ddd Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:41:16 +0100 Subject: [PATCH 2/3] fix: answer github_issue_failed with a 503, which Cloudflare passes through --- README.md | 4 ++-- app/Enums/ApiErrorCode.php | 3 +-- tests/Feature/Controllers/Api/IssueControllerTest.php | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 05bce081b..7dcfeb679 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. -`POST /api/v1/issues` opens a public issue on `nunomaduro/dev-null.sh`, as the web's `/feature` and `/bug` commands do. It takes a `command`, `/feature` or `/bug`, and a `body` of 3 to 4,000 characters, with blank lines collapsed first, and answers `201` with the issue's `number` and `url`. The GitHub App opens it, crediting the user, labelled `enhancement` or `bug`. Each user can open 5 issues an hour, on the web and in the app together, and only issues that were opened count. When GitHub rejects the issue or doesn't respond, it's a `502` with `github_issue_failed`, and the message says to check upstream first if GitHub may have opened it anyway. +`POST /api/v1/issues` opens a public issue on `nunomaduro/dev-null.sh`, as the web's `/feature` and `/bug` commands do. It takes a `command`, `/feature` or `/bug`, and a `body` of 3 to 4,000 characters, with blank lines collapsed first, and answers `201` with the issue's `number` and `url`. The GitHub App opens it, crediting the user, labelled `enhancement` or `bug`. Each user can open 5 issues an hour, on the web and in the app together, and only issues that were opened count. When GitHub rejects the issue or doesn't respond, it's a `503` with `github_issue_failed`, and the message says to check upstream first if GitHub may have opened it anyway. `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. @@ -142,8 +142,8 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `404` | `commit_unavailable` | The commit to report doesn't exist, was deleted, isn't published, or is across a block. | | `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. | | `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 5 opened issues an hour, 60 pastéis a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | -| `502` | `github_issue_failed` | `POST /api/v1/issues` only: GitHub rejected the issue or didn't respond. When it didn't respond, it may have opened the issue anyway. Neither counts against the limit. | | `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. | +| `503` | `github_issue_failed` | `POST /api/v1/issues` only: GitHub rejected the issue or didn't respond. When it didn't respond, it may have opened the issue anyway. Neither counts against the limit. | Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for a commit that doesn't exist, except when reporting it. Unsigned requests to the public endpoints get the same errors as before, without codes. diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index dc0251af3..aedfc515c 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -31,8 +31,7 @@ public function status(): int self::CommitUnavailable => 404, self::PasteisExhausted => 422, self::RateLimited => 429, - self::GitHubIssueFailed => 502, - self::GitHubSyncFailed => 503, + self::GitHubSyncFailed, self::GitHubIssueFailed => 503, }; } } diff --git a/tests/Feature/Controllers/Api/IssueControllerTest.php b/tests/Feature/Controllers/Api/IssueControllerTest.php index ad4474e0e..0136191e7 100644 --- a/tests/Feature/Controllers/Api/IssueControllerTest.php +++ b/tests/Feature/Controllers/Api/IssueControllerTest.php @@ -55,7 +55,7 @@ ]); signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.']) - ->assertStatus(502) + ->assertServiceUnavailable() ->assertExactJson(['message' => $message, 'code' => 'github_issue_failed']); })->with([ 'rejected' => [fn () => Http::response(['message' => 'Validation Failed'], 422), 'GitHub did not create the issue. Your draft is safe.'], @@ -112,7 +112,7 @@ signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/deploy', 'body' => 'Nope.'])->assertUnprocessable(); foreach (range(1, 6) as $attempt) { - signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.'])->assertStatus(502); + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), ['command' => '/bug', 'body' => 'Broken.'])->assertServiceUnavailable(); $this->travel(11)->seconds(); } From 3995de36609b48c896f1e4f642932091915e800e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 20 Oct 2026 00:43:08 +0100 Subject: [PATCH 3/3] fix: take the web's issue body minimum and environment details in the API --- README.md | 2 +- app/Http/Controllers/Api/IssueController.php | 2 +- app/Http/Requests/CreateApiIssueRequest.php | 19 +++++++++++++++++-- .../Controllers/Api/IssueControllerTest.php | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7dcfeb679..f876c7d0e 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. -`POST /api/v1/issues` opens a public issue on `nunomaduro/dev-null.sh`, as the web's `/feature` and `/bug` commands do. It takes a `command`, `/feature` or `/bug`, and a `body` of 3 to 4,000 characters, with blank lines collapsed first, and answers `201` with the issue's `number` and `url`. The GitHub App opens it, crediting the user, labelled `enhancement` or `bug`. Each user can open 5 issues an hour, on the web and in the app together, and only issues that were opened count. When GitHub rejects the issue or doesn't respond, it's a `503` with `github_issue_failed`, and the message says to check upstream first if GitHub may have opened it anyway. +`POST /api/v1/issues` opens a public issue on `nunomaduro/dev-null.sh`, as the web's `/feature` and `/bug` commands do. It takes a `command`, `/feature` or `/bug`, and a `body` of 3 to 4,000 characters, with blank lines collapsed first, and answers `201` with the issue's `number` and `url`. It may also send the web's `environment` details (`browser`, `os`, `viewport`, `pixel_ratio`, `language`, `color_scheme` and `page`, up to 100 characters each), which go in a collapsed table in the issue. A first `# heading` in the body becomes the title, as on the web. The GitHub App opens it, crediting the user, labelled `enhancement` or `bug`. Each user can open 5 issues an hour, on the web and in the app together, and only issues that were opened count. When GitHub rejects the issue or doesn't respond, it's a `503` with `github_issue_failed`, and the message says to check upstream first if GitHub may have opened it anyway. `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. diff --git a/app/Http/Controllers/Api/IssueController.php b/app/Http/Controllers/Api/IssueController.php index 417404d0d..89520df71 100644 --- a/app/Http/Controllers/Api/IssueController.php +++ b/app/Http/Controllers/Api/IssueController.php @@ -23,7 +23,7 @@ public function store(CreateApiIssueRequest $request, #[CurrentUser] User $user, CreateIssue $action): JsonResponse { try { - $issue = $action->handle($user, $request->string('command')->value(), $request->string('body')->value()); + $issue = $action->handle($user, $request->string('command')->value(), $request->string('body')->value(), $request->environment()); } catch (ConnectionException) { return new ApiException(ApiErrorCode::GitHubIssueFailed, __('GitHub did not respond. Check the upstream issues before trying again. Your draft is safe.'))->render(); } catch (RequestException) { diff --git a/app/Http/Requests/CreateApiIssueRequest.php b/app/Http/Requests/CreateApiIssueRequest.php index 1b298c270..cadd19444 100644 --- a/app/Http/Requests/CreateApiIssueRequest.php +++ b/app/Http/Requests/CreateApiIssueRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests; +use App\Models\Commit; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\In; @@ -24,7 +25,8 @@ public function prepareForValidation(): void } /** - * An issue from the mobile app, sent as JSON, with the web's commands and its limits on a commit's body. + * An issue from the mobile app, sent as JSON, with the web's rules: its commands, a body long enough to act on, and + * the same environment details the web sends about the browser and screen. * * @return array> */ @@ -32,7 +34,20 @@ public function rules(): array { return [ 'command' => ['required', Rule::in(['/feature', '/bug'])], - 'body' => CreateCommitRequest::BODY_RULES, + 'body' => ['required', 'string', 'min:'.CreateIssueRequest::MIN_BODY_LENGTH, 'max:'.Commit::MAX_BODY_LENGTH], + 'environment' => ['nullable', 'array:'.implode(',', CreateIssueRequest::ENVIRONMENT_FIELDS)], + 'environment.*' => ['nullable', 'string', 'max:100'], ]; } + + /** + * @return array + */ + public function environment(): array + { + /** @var array $environment */ + $environment = $this->array('environment'); + + return array_filter($environment, fn (?string $value): bool => $value !== null && $value !== ''); + } } diff --git a/tests/Feature/Controllers/Api/IssueControllerTest.php b/tests/Feature/Controllers/Api/IssueControllerTest.php index 0136191e7..beb8e922b 100644 --- a/tests/Feature/Controllers/Api/IssueControllerTest.php +++ b/tests/Feature/Controllers/Api/IssueControllerTest.php @@ -47,6 +47,19 @@ && str_starts_with((string) $request['body'], "Dark mode\n\nplease\n\n---")); }); +it('folds the environment the app sends into the body, as the web does', function (): void { + fakeGitHubApp(); + + signedApiRequest($this->keyPair, 'POST', route('api-issue.store'), [ + 'command' => '/bug', + 'body' => 'Broken.', + 'environment' => ['browser' => 'devnull app', 'os' => 'iOS 27.0', 'language' => ''], + ])->assertCreated(); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/issues') + && str_starts_with((string) $request['body'], "Broken.\n\n
\nEnvironment\n\n| | |\n|---|---|\n| Browser | `devnull app` |\n| Os | `iOS 27.0` |\n\n
\n\n---")); +}); + it('keeps the draft when GitHub rejects the issue or does not respond, with a code', function (Closure $response, string $message): void { Http::fake([ 'api.github.com/repos/nunomaduro/dev-null.sh/installation' => Http::response(['id' => 99]), @@ -75,7 +88,9 @@ 'no command' => [['body' => 'Ship it.'], 'command'], 'missing body' => [['command' => '/bug'], 'body'], 'blank lines only' => [['command' => '/bug', 'body' => "a\n\n\n\n"], 'body'], + 'too short for an issue' => [['command' => '/bug', 'body' => 'ab'], 'body'], 'too long' => [['command' => '/bug', 'body' => str_repeat('a', 4001)], 'body'], + 'unknown environment field' => [['command' => '/bug', 'body' => 'Broken.', 'environment' => ['cookie' => 'secret']], 'environment'], ]); it('shares the web limit of five opened issues an hour, answering with a code', function (): void {