From e9adceb4c0612625409355468503d09227c26221 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:11:38 +0100 Subject: [PATCH] Turn a new commit into a /feature or /bug issue, as the web does Typing /feature or /bug and a space at the start of the compose sheet turns the draft into an issue, with the web's badge, placeholders and Open issue. A slash offers the web's command menu, and a draft that mentions a bug or a feature gets the web's hint. Opening it sends POST /api/v1/issues (nunomaduro/dev-null.sh#653) in the background, then the sheet says "Opened issue #123." with View issue, as the web's toast does. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 5 + app/Devnull/ErrorCode.php | 6 +- app/Devnull/IssueCommand.php | 108 ++++++++ app/Icons/Lucide.php | 4 + .../Concerns/DraftsIssues.php | 204 +++++++++++++++ .../Concerns/OpensCommitSheet.php | 9 +- resources/icons/bug.svg | 1 + resources/icons/lightbulb.svg | 1 + .../native/partials/commit-sheet.blade.php | 41 ++- .../native/partials/issue-badge.blade.php | 16 ++ .../partials/issue-suggestions.blade.php | 34 +++ tests/Feature/Devnull/IssueCommandTest.php | 49 ++++ tests/Feature/Screens/IssueCommandsTest.php | 240 ++++++++++++++++++ 13 files changed, 710 insertions(+), 8 deletions(-) create mode 100644 app/Devnull/IssueCommand.php create mode 100644 app/NativeComponents/Concerns/DraftsIssues.php create mode 100644 resources/icons/bug.svg create mode 100644 resources/icons/lightbulb.svg create mode 100644 resources/views/native/partials/issue-badge.blade.php create mode 100644 resources/views/native/partials/issue-suggestions.blade.php create mode 100644 tests/Feature/Devnull/IssueCommandTest.php create mode 100644 tests/Feature/Screens/IssueCommandsTest.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 223f097..ffccb5e 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -80,6 +80,11 @@ public const string OrgsPath = 'remotes/orgs'; + /** + * Where a `/feature` or `/bug` draft goes, to be opened as an issue upstream. + */ + public const string IssuesPath = 'issues'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} /** diff --git a/app/Devnull/ErrorCode.php b/app/Devnull/ErrorCode.php index 6a05ac1..f67ba27 100644 --- a/app/Devnull/ErrorCode.php +++ b/app/Devnull/ErrorCode.php @@ -24,12 +24,14 @@ enum ErrorCode: string case OwnCommit = 'own_commit'; case CommitUnavailable = 'commit_unavailable'; case GitHubSyncFailed = 'github_sync_failed'; + case GitHubIssueFailed = 'github_issue_failed'; case PasteisExhausted = 'pasteis_exhausted'; /** * What the app says for it, or null where devnull's own message says it * better, as for a rate limit, whose message names the limit hit, or for - * your own commit, whose message names what you can't do to it. + * your own commit, whose message names what you can't do to it, or for an + * issue GitHub didn't open, whose message says whether to check upstream. * A blocked or unavailable profile has a screen of its own (see Profile). */ public function message(): ?string @@ -46,7 +48,7 @@ public function message(): ?string self::CommitUnavailable => "fatal: this commit isn't available.", self::GitHubSyncFailed => 'Could not sync from GitHub. Try again in a minute.', self::PasteisExhausted => Pasteis::Exhausted, - self::RateLimited, self::OwnCommit => null, + self::RateLimited, self::OwnCommit, self::GitHubIssueFailed => null, }; } } diff --git a/app/Devnull/IssueCommand.php b/app/Devnull/IssueCommand.php new file mode 100644 index 0000000..c33c490 --- /dev/null +++ b/app/Devnull/IssueCommand.php @@ -0,0 +1,108 @@ +value; + } + + public function description(): string + { + return match ($this) { + self::Feature => 'Request a feature', + self::Bug => 'Report a bug', + }; + } + + /** + * The web's Markdown template. devnull takes a first `# heading` as the issue's title. + */ + public function placeholder(): string + { + return match ($this) { + self::Feature => "# Feature title\n\n## Problem\nWhat is missing?\n\n## Proposal\nHow should it work?", + self::Bug => "# Bug title\n\n## Steps\n1. How to reproduce it\n\n## Expected vs. actual\nWhat should happen, and what happens instead?", + }; + } + + /** + * The question under a plain draft that mentions a feature or a bug. + */ + public function hint(): string + { + return match ($this) { + self::Feature => 'Have an idea for /dev/null?', + self::Bug => 'Found a bug in /dev/null?', + }; + } + + public function icon(): Lucide + { + return match ($this) { + self::Feature => Lucide::Lightbulb, + self::Bug => Lucide::Bug, + }; + } + + /** + * The commands a draft could still become while it's only a slash and the start of a name. + * + * @return list + */ + public static function suggested(string $body): array + { + if (preg_match('/^\/[a-z]*$/', $body) !== 1) { + return []; + } + + return array_values(array_filter(self::cases(), fn (self $command): bool => str_starts_with($command->command(), $body))); + } + + /** + * A complete command followed by a space or a new line turns the draft into an issue, without the command. + * + * @return array{0: self, 1: string}|null + */ + public static function typed(string $body): ?array + { + if (preg_match('/^\/(feature|bug)\s/u', $body, $match) !== 1) { + return null; + } + + return [self::from($match[1]), substr($body, strlen($match[0]))]; + } + + /** + * The command a plain draft seems to be about, when it mentions a bug or a feature, so it can be suggested. + */ + public static function hinted(string $body): ?self + { + if (str_starts_with($body, '/') || preg_match('/\b(bugs?|features?)\b/i', $body, $match) !== 1) { + return null; + } + + return str_starts_with(strtolower($match[1]), 'bug') ? self::Bug : self::Feature; + } +} diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index b65a796..a5336db 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -63,6 +63,8 @@ enum Lucide case Search; case Reply; case SmilePlus; + case Lightbulb; + case Bug; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -152,6 +154,8 @@ public function custom(bool $filled = false): ?string self::Search => 'search', self::Reply => 'reply', self::SmilePlus => 'smile-plus', + self::Lightbulb => 'lightbulb', + self::Bug => 'bug', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php new file mode 100644 index 0000000..b3779f3 --- /dev/null +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -0,0 +1,204 @@ +canDraftIssue() && $this->sheetIssue === null && ($picked = IssueCommand::tryFrom($command)) !== null) { + $this->sheetIssue = $picked->value; + $this->sheetBody = ''; + $this->updatedSheetBody(); + } + } + + /** + * The hint's Use /bug or Use /feature: the draft becomes that issue as it is. + */ + public function useIssueCommand(string $command): void + { + if ($this->canDraftIssue() && $this->sheetIssue === null && ($used = IssueCommand::tryFrom($command)) !== null) { + $this->sheetIssue = $used->value; + } + } + + /** + * The badge's X: back to a commit, with what you wrote. + */ + public function cancelIssue(): void + { + if (! $this->openingIssue) { + $this->sheetIssue = null; + $this->sheetError = null; + } + } + + public function dismissIssueHint(): void + { + Cache::forever(self::IssueHintDismissedKey, true); + $this->issueHintsDismissed = true; + } + + /** + * Sends the issue draft to devnull, which has its GitHub App open it upstream, crediting you. + */ + public function openIssueFromSheet(): void + { + $command = $this->sheetIssue === null ? null : IssueCommand::tryFrom($this->sheetIssue); + $body = trim($this->sheetBody); + + if ($command === null || $this->openingIssue || ! $this->canDraftIssue() || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { + return; + } + + $this->openingIssue = true; + $this->sheetError = null; + + $this->writeInBackground('POST', Client::IssuesPath, ['command' => $command->command(), 'body' => $body], function (array $json): void { + $number = is_int($json['number'] ?? null) ? $json['number'] : null; + app(Stash::class)->put($this->me()?->id, 'commit', ''); + + // Swiped away while it was on its way, so a toast says it, without View issue. + if (! $this->sheetOpen) { + Dialog::toast("Opened issue #{$number}."); + + return; + } + + $this->forgetIssueDraft(); + $this->sheetBody = ''; + $this->sheetRestored = false; + $this->openedIssueNumber = $number; + $this->openedIssueUrl = is_string($json['url'] ?? null) ? $json['url'] : null; + $this->sheetView = 'issued'; + }, function (string $message, ?ApiException $exception = null): void { + $this->openingIssue = false; + $this->sheetError = match (true) { + $exception?->status === 0 => 'Connection lost. Check upstream before retrying. Your draft is safe.', + // A proxy's own page for a 502 or 504 has no code, and the issue may be open anyway. + $exception !== null && $exception->status >= 500 && $exception->code() === null => 'GitHub did not confirm the issue. Check upstream before retrying. Your draft is safe.', + default => $message, + }; + }); + } + + /** + * View issue, beside "Opened issue #123.", as the web's toast offers it. + */ + public function viewOpenedIssue(): void + { + if ($this->openedIssueUrl !== null) { + Browser::inApp($this->openedIssueUrl); + } + } + + /** + * Turns the draft into an issue when it starts with a command and a space or a new line, as on the web. + */ + protected function typeIssueCommand(): void + { + $this->sheetTyped = true; + + if ($this->canDraftIssue() && $this->sheetIssue === null && ($typed = IssueCommand::typed($this->sheetBody)) !== null) { + [$command, $this->sheetBody] = $typed; + $this->sheetIssue = $command->value; + } + } + + /** + * Only a new commit can become an issue, as on the web. + */ + protected function canDraftIssue(): bool + { + return $this->sheetOpen && $this->sheetView === 'compose'; + } + + /** + * The sheet starts again as a commit, with the menu shut until you type. + */ + protected function forgetIssueDraft(): void + { + $this->issueHintsDismissed = Cache::get(self::IssueHintDismissedKey) === true; + $this->sheetIssue = null; + $this->sheetTyped = false; + $this->openingIssue = false; + $this->openedIssueNumber = null; + $this->openedIssueUrl = null; + } +} diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index d919c90..6ae0ff2 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -24,11 +24,16 @@ * Opened again, it starts from the draft, marked as restored. It's forgotten * once it's sent. * + * A new commit's draft can become a `/feature` or `/bug` issue, as on the web + * (see DraftsIssues). + * * The sheet keeps its commit while it slides away, so it doesn't empty on the * way down, and lets go of it once iOS says it's gone, however it was closed. */ trait OpensCommitSheet { + use DraftsIssues; + /** * Whether the sheet is up, or on its way up. */ @@ -142,6 +147,7 @@ public function composeInSheet(): void */ public function updatedSheetBody(): void { + $this->typeIssueCommand(); $name = $this->sheetStashName(); if (! $this->sheetOpen || $name === null) { @@ -174,7 +180,7 @@ public function commitFromSheet(): void $stashName = $this->sheetStashName(); $body = trim($this->sheetBody); - if ($stashName === null || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { + if ($stashName === null || $this->sheetIssue !== null || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { return; } @@ -237,6 +243,7 @@ private function forgetCommitSheet(): void $this->sheetBody = ''; $this->sheetRestored = false; $this->sheetError = null; + $this->forgetIssueDraft(); } /** diff --git a/resources/icons/bug.svg b/resources/icons/bug.svg new file mode 100644 index 0000000..2dcc1f3 --- /dev/null +++ b/resources/icons/bug.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/lightbulb.svg b/resources/icons/lightbulb.svg new file mode 100644 index 0000000..156778e --- /dev/null +++ b/resources/icons/lightbulb.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 983703f..5d97e7e 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -10,22 +10,30 @@ which waits for a reason. Once devnull has the report, the sheet says so, with Done. If devnull says no, why shows here and the form keeps what you wrote. - It's full height for the forms, and short for the receipt. It's always on the screen, shown or not, so iOS can + A new commit can become a `/feature` or `/bug` issue, as on the web (see DraftsIssues): the command's badge over the + field, its placeholder in it, and Open issue for Commit. Opened, the sheet says so, with View issue and Done. + + It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can slide it away and say when it's gone. --}} - - @if ($sheetCommit || $sheetView === 'compose') + + @if ($sheetCommit || in_array($sheetView, ['compose', 'issued'], true)) @if (in_array($sheetView, ['quote', 'compose'], true)) @php($sheetLength = mb_strlen(trim($sheetBody))) @php($quoting = $sheetView === 'quote') + @php($issueCommand = $sheetIssue === null ? null : \App\Devnull\IssueCommand::from($sheetIssue)) {{ $quoting ? 'Quote '.$sheetCommit->author->handle() : 'New commit' }} + @if ($issueCommand) + @include('native.partials.issue-badge', ['command' => $issueCommand]) + @endif + $sheetCommit, 'static' => true]) + @elseif (! $issueCommand) + @include('native.partials.issue-suggestions', [ + 'suggestions' => $sheetTyped ? \App\Devnull\IssueCommand::suggested($sheetBody) : [], + 'hint' => $issueHintsDismissed ? null : \App\Devnull\IssueCommand::hinted($sheetBody), + ]) @endif @if ($sheetError) @@ -49,7 +62,12 @@ class="w-full text-theme-ink" @endif @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget']) - Commit + @if ($issueCommand) + {{-- The label stays put while it spins, so the button keeps its size. --}} + Open issue + @else + Commit + @endif @@ -100,6 +118,19 @@ class="w-full" Done + @elseif ($sheetView === 'issued') + + + + Opened issue #{{ $openedIssueNumber }}. + + + @if ($openedIssueUrl) + View issue + @endif + Done + + @endif @endif diff --git a/resources/views/native/partials/issue-badge.blade.php b/resources/views/native/partials/issue-badge.blade.php new file mode 100644 index 0000000..410db8c --- /dev/null +++ b/resources/views/native/partials/issue-badge.blade.php @@ -0,0 +1,16 @@ +{{-- + The web's IssueCommandBadge, over the field of a draft that's an issue: $command in a soft chip, whose X turns the + draft back into a commit, then where the issue goes. The X's target is the chip's full height. +--}} + + + + {{ $command->command() }} + + + + + + + Public issue on {{ \App\Devnull\IssueCommand::Repository }}, crediting you + diff --git a/resources/views/native/partials/issue-suggestions.blade.php b/resources/views/native/partials/issue-suggestions.blade.php new file mode 100644 index 0000000..b2380fb --- /dev/null +++ b/resources/views/native/partials/issue-suggestions.blade.php @@ -0,0 +1,34 @@ +{{-- + Under a new commit's field, the web's two ways into an issue (see DraftsIssues). While the draft is only a slash and + the start of a command, the web's command menu lists $suggestions, and a tap starts that issue, empty. A plain draft + that mentions a bug or a feature gets the web's $hint instead: Use /bug makes the draft that issue as it is, and the + X stops the hints on this phone. +--}} +@if ($suggestions !== []) + + @foreach ($suggestions as $suggestion) + + + + {{ $suggestion->command() }} + {{ $suggestion->description() }} + + + @endforeach + +@elseif ($hint) + + + {{ $hint->hint() }} + + + Use {{ $hint->command() }} + + + + + + + + +@endif diff --git a/tests/Feature/Devnull/IssueCommandTest.php b/tests/Feature/Devnull/IssueCommandTest.php new file mode 100644 index 0000000..c4c2278 --- /dev/null +++ b/tests/Feature/Devnull/IssueCommandTest.php @@ -0,0 +1,49 @@ + array_map(fn (IssueCommand $command): string => $command->command(), IssueCommand::suggested($body)); + + expect($names('/'))->toBe(['/feature', '/bug']) + ->and($names('/f'))->toBe(['/feature']) + ->and($names('/bug'))->toBe(['/bug']) + ->and($names('/deploy'))->toBe([]) + ->and($names('/bug '))->toBe([]) + ->and($names('Shipped /bug fixes'))->toBe([]) + ->and($names(''))->toBe([]); +}); + +it('turns a complete command and a space or new line into an issue without the command', function () { + expect(IssueCommand::typed('/bug '))->toBe([IssueCommand::Bug, '']) + ->and(IssueCommand::typed('/feature Dark mode'))->toBe([IssueCommand::Feature, 'Dark mode']) + ->and(IssueCommand::typed("/bug\nSteps"))->toBe([IssueCommand::Bug, 'Steps']) + ->and(IssueCommand::typed('/bug'))->toBeNull() + ->and(IssueCommand::typed('/bugs are fixed'))->toBeNull() + ->and(IssueCommand::typed('Fixed a /bug today'))->toBeNull(); +}); + +it('hints the command a plain draft mentions', function () { + $name = fn (string $body): ?string => IssueCommand::hinted($body)?->command(); + + expect($name('The search has a bug'))->toBe('/bug') + ->and($name('Bugs everywhere today'))->toBe('/bug') + ->and($name('Feature idea: dark mode for the CLI'))->toBe('/feature') + ->and($name('Would love these features'))->toBe('/feature') + ->and($name('Debugging the parser'))->toBeNull() + ->and($name('Featured on the front page'))->toBeNull() + ->and($name('/bug already a command'))->toBeNull() + ->and($name('Shipped the parser'))->toBeNull(); +}); + +it('words each command as the web does', function () { + expect(array_map(fn (IssueCommand $command): array => [$command->command(), $command->description(), $command->placeholder(), $command->hint()], IssueCommand::cases()))->toBe([ + ['/feature', 'Request a feature', "# Feature title\n\n## Problem\nWhat is missing?\n\n## Proposal\nHow should it work?", 'Have an idea for /dev/null?'], + ['/bug', 'Report a bug', "# Bug title\n\n## Steps\n1. How to reproduce it\n\n## Expected vs. actual\nWhat should happen, and what happens instead?", 'Found a bug in /dev/null?'], + ]); +}); diff --git a/tests/Feature/Screens/IssueCommandsTest.php b/tests/Feature/Screens/IssueCommandsTest.php new file mode 100644 index 0000000..d3ff88c --- /dev/null +++ b/tests/Feature/Screens/IssueCommandsTest.php @@ -0,0 +1,240 @@ + 'http://devnull.test']); + signIn(); + $this->userId = userJson()['id']; +}); + +/** + * A Following log with one commit of Ada's, devnull answering a new issue with $answer, and the compose sheet open + * from the nav bar. + * + * @param array{0: mixed, 1?: int}|null $answer + */ +function issueSheetOnLog(?array $answer = null): TestableComponent +{ + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'An older commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], + 'POST /api/v1/issues' => $answer ?? [['number' => 123, 'url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123'], 201], + ]); + + return Native::visit('/log', platform: 'ios')->press('composeInSheet'); +} + +function issueRequestsSent(): int +{ + return collect(Http::recorded())->filter(fn (array $sent): bool => str_ends_with($sent[0]->url(), '/api/v1/issues'))->count(); +} + +function hasRef(TestableComponent $screen, string $ref): bool +{ + return findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === $ref) !== null; +} + +it('turns a new commit into the issue its command names, without the command, as the web does', function (string $typed, string $command, string $body, string $placeholder) { + $screen = issueSheetOnLog()->input('sheet-body', $typed); + + expect($screen->instance()->sheetIssue)->toBe(ltrim($command, '/')) + ->and(nodeByRef($screen, 'sheet-body')['props'])->toMatchArray(['value' => $body, 'placeholder' => $placeholder]) + ->and(hasRef($screen, 'sheet-open-issue'))->toBeTrue() + ->and(hasRef($screen, 'sheet-commit'))->toBeFalse(); + + $screen->assertSee($command)->assertSee('Public issue on nunomaduro/dev-null.sh, crediting you'); +})->with([ + 'feature' => ['/feature Dark mode', '/feature', 'Dark mode', IssueCommand::Feature->placeholder()], + 'bug' => ['/bug ', '/bug', '', IssueCommand::Bug->placeholder()], + 'bug on a new line' => ["/bug\nSteps", '/bug', 'Steps', IssueCommand::Bug->placeholder()], +]); + +it('keeps a draft that only looks like a command a commit', function (string $typed) { + $screen = issueSheetOnLog()->input('sheet-body', $typed); + + expect($screen->instance()->sheetIssue)->toBeNull() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe($typed) + ->and(hasRef($screen, 'issue-badge'))->toBeFalse() + ->and(hasRef($screen, 'sheet-commit'))->toBeTrue(); +})->with(['/bug', '/bugs are fixed', 'Fixed a /bug today']); + +it('offers the commands a slash could become, and starts the one you tap empty', function () { + $screen = issueSheetOnLog()->input('sheet-body', '/'); + + expect(childRefs($screen, 'issue-commands'))->toBe(['issue-command-feature', 'issue-command-bug']); + $screen->assertSee('Request a feature')->assertSee('Report a bug'); + + $screen->input('sheet-body', '/f'); + expect(childRefs($screen, 'issue-commands'))->toBe(['issue-command-feature']); + + $screen->input('sheet-body', '/deploy'); + expect(hasRef($screen, 'issue-commands'))->toBeFalse(); + + $screen->input('sheet-body', '/b')->tap('issue-command-bug'); + + expect($screen->instance()->sheetIssue)->toBe('bug') + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') + ->and(hasRef($screen, 'issue-commands'))->toBeFalse() + ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe(''); +}); + +it('opens the menu for a restored slash only once you type, as the web opens it only while you type', function () { + app(Stash::class)->put($this->userId, 'commit', '/'); + + $screen = issueSheetOnLog(); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('/') + ->and(hasRef($screen, 'issue-commands'))->toBeFalse(); + + $screen->input('sheet-body', '/b'); + + expect(childRefs($screen, 'issue-commands'))->toBe(['issue-command-bug']); +}); + +it('hints the command a plain draft mentions, and turns it into that issue as it is', function () { + $screen = issueSheetOnLog()->input('sheet-body', 'The search has a bug'); + + $screen->assertSee('Found a bug in /dev/null?')->assertSee('Use /bug'); + + $screen->tap('issue-hint-use'); + + expect($screen->instance()->sheetIssue)->toBe('bug') + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('The search has a bug') + ->and(hasRef($screen, 'issue-hint'))->toBeFalse(); +}); + +it('stops the hints on this phone once dismissed', function () { + $screen = issueSheetOnLog()->input('sheet-body', 'Feature idea: dark mode'); + + $screen->assertSee('Have an idea for /dev/null?')->tap('issue-hint-dismiss'); + + expect(hasRef($screen, 'issue-hint'))->toBeFalse() + ->and(Cache::get(Log::IssueHintDismissedKey))->toBeTrue(); + + $screen->dismissSheet('sheet')->press('composeInSheet'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Feature idea: dark mode') + ->and(hasRef($screen, 'issue-hint'))->toBeFalse(); +}); + +it('goes back to a commit from the badge, keeping what you wrote', function () { + $screen = issueSheetOnLog()->input('sheet-body', '/feature Dark mode')->tap('issue-cancel'); + + expect($screen->instance()->sheetIssue)->toBeNull() + ->and(nodeByRef($screen, 'sheet-body')['props'])->toMatchArray(['value' => 'Dark mode', 'placeholder' => 'What did you ship?']) + ->and(hasRef($screen, 'sheet-commit'))->toBeTrue(); +}); + +it('only turns a new commit into an issue, never a quote', function () { + serveApi(['GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])]]); + + $screen = Native::visit('/log', platform: 'ios')->tap('quote-c1')->input('sheet-body', '/bug '); + + expect($screen->instance()->sheetIssue)->toBeNull() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('/bug ') + ->and(hasRef($screen, 'issue-commands'))->toBeFalse(); + + $screen->input('sheet-body', '/')->input('sheet-body', 'Has a bug'); + + expect(hasRef($screen, 'issue-commands'))->toBeFalse() + ->and(hasRef($screen, 'issue-hint'))->toBeFalse(); +}); + +it('keeps Open issue disabled until the issue is long enough, and short enough', function () { + $screen = issueSheetOnLog()->input('sheet-body', '/bug ab'); + + expect(nodeByRef($screen, 'sheet-open-issue')['props']['disabled'])->toBeTrue(); + + $screen->input('sheet-body', 'abc'); + expect(nodeByRef($screen, 'sheet-open-issue')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->input('sheet-body', str_repeat('a', 4001)); + expect(nodeByRef($screen, 'sheet-open-issue')['props']['disabled'])->toBeTrue(); + + // Pressing it anyway, or Commit, sends nothing. + $screen->call('openIssueFromSheet')->call('commitFromSheet'); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); +}); + +it('opens the issue while the button spins, then says so with View issue, as the web toast does', function () { + $bridge = holdRefreshes(); + $screen = issueSheetOnLog()->input('sheet-body', "/bug Search loses its filters\n\nSteps: select PHP."); + $list = nodeByRef($screen, 'log')['id']; + + $screen->tap('sheet-open-issue')->tap('sheet-open-issue'); + + expect(nodeByRef($screen, 'sheet-open-issue')['props'])->toMatchArray(['label' => 'Open issue', 'loading' => true, 'disabled' => true]) + ->and(issueRequestsSent())->toBe(0); + + finishRefreshes($screen)->assertNoNavigation()->assertSee('Opened issue #123.'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'http://devnull.test/api/v1/'.Client::IssuesPath + && $request->body() === '{"command":"/bug","body":"Search loses its filters\n\nSteps: select PHP."}' + && $request->hasHeader('X-SSH-Signature')); + + expect(issueRequestsSent())->toBe(1) + ->and(nodeByRef($screen, 'sheet')['props'])->toMatchArray(['visible' => true, 'detents' => '0.3']) + ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('') + ->and(nodeByRef($screen, 'log')['id'])->toBe($list) + ->and($screen->instance()->commits)->toHaveCount(1); + $bridge->assertNotCalled('Dialog.Toast'); + + $screen->tap('view-issue') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/nunomaduro/dev-null.sh/issues/123'); + + $screen->tap('issue-done'); + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); + + // Opened again, it's a new commit. + $screen->dismissSheet('sheet')->press('composeInSheet'); + expect(nodeByRef($screen, 'sheet-body')['props'])->toMatchArray(['value' => '', 'placeholder' => 'What did you ship?']) + ->and(hasRef($screen, 'sheet-commit'))->toBeTrue(); +}); + +it('says so in a toast when the sheet was swiped away before the issue opened', function () { + holdRefreshes(); + $screen = issueSheetOnLog()->input('sheet-body', '/feature Dark mode')->tap('sheet-open-issue')->dismissSheet('sheet'); + + finishRefreshes($screen) + ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'Opened issue #123.'); + + expect(app(Stash::class)->get($this->userId, 'commit'))->toBe('') + ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); +}); + +it('keeps the issue draft with why when devnull or GitHub says no', function (array $answer, string $message) { + $screen = issueSheetOnLog($answer)->input('sheet-body', '/bug Broken.')->tap('sheet-open-issue'); + + expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe($message) + ->and($screen->instance()->sheetIssue)->toBe('bug') + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Broken.') + ->and(nodeByRef($screen, 'sheet-open-issue')['props']['disabled'] ?? false)->toBeFalse() + ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('Broken.'); +})->with([ + 'over the limit' => [[['message' => 'Issue limit reached. Try again in an hour.', 'code' => 'rate_limited'], 429], 'Issue limit reached. Try again in an hour.'], + 'GitHub said no' => [[['message' => 'GitHub did not create the issue. Your draft is safe.', 'code' => 'github_issue_failed'], 503], 'GitHub did not create the issue. Your draft is safe.'], + 'GitHub did not answer' => [[['message' => 'GitHub did not respond. Check the upstream issues before trying again. Your draft is safe.', 'code' => 'github_issue_failed'], 503], 'GitHub did not respond. Check the upstream issues before trying again. Your draft is safe.'], + "a proxy's error page" => [['502: Bad gateway', 502], 'GitHub did not confirm the issue. Check upstream before retrying. Your draft is safe.'], + 'invalid' => [[['message' => 'The body field must not be greater than 4000 characters.', 'errors' => ['body' => ['The body field must not be greater than 4000 characters.']]], 422], 'The body field must not be greater than 4000 characters.'], +]); + +it('says to check upstream when the connection drops on the way', function () { + $screen = issueSheetOnLog()->input('sheet-body', '/bug Broken.'); + + serveApi(fn () => throw new ConnectionException('cURL error 28: Operation timed out')); + + $screen->tap('sheet-open-issue'); + + expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('Connection lost. Check upstream before retrying. Your draft is safe.') + ->and($screen->instance()->sheetIssue)->toBe('bug'); +});