Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

/**
Expand Down
6 changes: 4 additions & 2 deletions app/Devnull/ErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
}
}
108 changes: 108 additions & 0 deletions app/Devnull/IssueCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace App\Devnull;

use App\Icons\Lucide;

/**
* The web's `/feature` and `/bug` commands, which turn a new commit's draft
* into a public issue on nunomaduro/dev-null.sh, opened by devnull's GitHub
* App and crediting you. The wording, and when each is suggested, are the
* web's (resources/js/lib/issue-commands.ts).
*/
enum IssueCommand: string
{
case Feature = 'feature';
case Bug = 'bug';

/**
* Where the issue goes, as the web says it under the command.
*/
public const string Repository = 'nunomaduro/dev-null.sh';

/**
* What you type, and what devnull takes.
*/
public function command(): string
{
return '/'.$this->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<self>
*/
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;
}
}
4 changes: 4 additions & 0 deletions app/Icons/Lucide.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
204 changes: 204 additions & 0 deletions app/NativeComponents/Concerns/DraftsIssues.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

namespace App\NativeComponents\Concerns;

use App\Devnull\Client;
use App\Devnull\Data\Commit;
use App\Devnull\Exceptions\ApiException;
use App\Devnull\IssueCommand;
use App\Devnull\Stash;
use Illuminate\Support\Facades\Cache;
use Native\Mobile\Facades\Browser;
use Native\Mobile\Facades\Dialog;

/**
* The web's `/feature` and `/bug` commands, in the compose sheet (see
* OpensCommitSheet), as the web's composer has them for a new commit. Quotes
* and replies don't, on the web or here.
*
* Typing a command and a space at the start of the draft turns it into an
* issue draft, without the command: the command's badge goes above the
* field, which takes the command's placeholder, and Open issue takes
* Commit's place. The badge's X turns it back into a commit, with what you
* wrote.
*
* While the draft is only a slash and the start of a command, the web opens a
* menu of the commands it could become, but only while the composer has
* focus (#528), so a lone slash restored from the stash doesn't open it.
* EDGE tells PHP nothing about focus, so here the menu waits until you've
* typed in the sheet since it opened: the field is focused when the sheet
* opens, and it's the only place in the sheet to type. A restored slash
* opens the menu at your next keystroke. Tapping a command starts the issue
* with an empty draft, as picking one does on the web.
*
* A plain draft that mentions a bug or a feature gets the web's hint under
* it, which offers the command with the draft kept. Its X stops the hints
* on this phone, as the web's stops them in that browser.
*
* Opening the issue waits on GitHub, so it's sent in the background while the
* button spins (see WritesInBackground). Once it's open the draft goes, and
* the sheet says "Opened issue #123." with View issue, as the web's toast
* does. When it isn't, the draft stays an issue, with why under it: devnull's
* own words when it sends them, else the web's, which say to check upstream
* when the issue might have been opened anyway.
*/
trait DraftsIssues
{
/**
* Where this phone remembers that the hints were dismissed.
*/
public const string IssueHintDismissedKey = 'devnull.issue-hint-dismissed';

/**
* The command the sheet's draft is an issue for, 'feature' or 'bug', or null while it's a commit.
*/
public ?string $sheetIssue = null;

/**
* Whether you've typed in the sheet since it opened, which opens the command menu.
*/
public bool $sheetTyped = false;

/**
* Whether the hints were dismissed on this phone.
*/
public bool $issueHintsDismissed = false;

/**
* Whether the issue is on its way to GitHub.
*/
public bool $openingIssue = false;

/**
* The issue the sheet just opened, as devnull answered: its number and where it is.
*/
public ?int $openedIssueNumber = null;

public ?string $openedIssueUrl = null;

/**
* A command picked from the menu: the issue starts empty, as on the web.
*/
public function pickIssueCommand(string $command): void
{
if ($this->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;
}
}
Loading
Loading