diff --git a/README.md b/README.md index d6d6043a..4a4d06b0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ A Laravel Zero CLI for deploying and managing applications on [Laravel Cloud](ht - **PHP 8.3+** - **Composer** -- **GitHub CLI (`gh`)** — installed and authenticated (used for repo linking and GitHub API) - **Git** — for repository detection and `repo:config` +- **GitHub CLI (`gh`) or GitLab CLI (`glab`)** — optional, and only to create a repository from a directory that has no remote yet. Install and sign in to the one matching your provider. ## Installation diff --git a/app/Client/Requests/CreateApplicationRequestData.php b/app/Client/Requests/CreateApplicationRequestData.php index 19338baf..da6a1d9c 100644 --- a/app/Client/Requests/CreateApplicationRequestData.php +++ b/app/Client/Requests/CreateApplicationRequestData.php @@ -2,6 +2,8 @@ namespace App\Client\Requests; +use App\Enums\SourceProvider; + class CreateApplicationRequestData extends RequestData { public readonly ?string $rootDirectory; @@ -11,6 +13,7 @@ public function __construct( public readonly string $name, public readonly string $region, ?string $rootDirectory = null, + public readonly ?SourceProvider $sourceProvider = null, ) { // The API rejects a trailing slash, which is what shell completion gives you. $rootDirectory = rtrim((string) $rootDirectory, '/'); @@ -25,6 +28,7 @@ public function toRequestData(): array 'name' => $this->name, 'region' => $this->region, 'root_directory' => $this->rootDirectory, + 'source_control_provider_type' => $this->sourceProvider?->value, ]); } } diff --git a/app/Client/Requests/UpdateApplicationRequestData.php b/app/Client/Requests/UpdateApplicationRequestData.php index 50b49ead..74f0f89f 100644 --- a/app/Client/Requests/UpdateApplicationRequestData.php +++ b/app/Client/Requests/UpdateApplicationRequestData.php @@ -2,6 +2,8 @@ namespace App\Client\Requests; +use App\Enums\SourceProvider; + class UpdateApplicationRequestData extends RequestData { public function __construct( @@ -11,6 +13,7 @@ public function __construct( public readonly ?string $defaultEnvironmentId = null, public readonly ?string $repository = null, public readonly ?string $slackChannel = null, + public readonly ?SourceProvider $sourceProvider = null, ) { // } @@ -23,6 +26,7 @@ public function toRequestData(): array 'default_environment_id' => $this->defaultEnvironmentId, 'repository' => $this->repository, 'slack_channel' => $this->slackChannel, + 'source_control_provider_type' => $this->sourceProvider?->value, ]); } } diff --git a/app/Commands/ApplicationCreate.php b/app/Commands/ApplicationCreate.php index 0b46a1be..66327e53 100644 --- a/app/Commands/ApplicationCreate.php +++ b/app/Commands/ApplicationCreate.php @@ -5,6 +5,7 @@ use App\Client\Requests\CreateApplicationRequestData; use App\Concerns\DeterminesDefaultRegion; use App\Concerns\RequiresRemoteGitRepo; +use App\Concerns\ResolvesSourceProvider; use App\Dto\Application; use App\Dto\Region; use App\Git; @@ -20,10 +21,12 @@ class ApplicationCreate extends BaseCommand use DeterminesDefaultRegion; use RequiresRemoteGitRepo; + use ResolvesSourceProvider; protected $signature = 'application:create {--name= : Application name} {--repository= : Repository (owner/repo format)} + {--source-provider= : Source provider (github, gitlab, gitlab_self_hosted). Default: detected from the origin remote} {--region= : Application region} {--root-directory= : Repository subdirectory containing the app (monorepos)}'; @@ -68,12 +71,14 @@ protected function createApplication() fn (?string $value) => text( label: 'Repository', required: true, - default: $value ?? ($git->hasGitHubRemote() ? $git->remoteRepo() : ''), + default: $value ?? ($git->hasRemote() ? $git->remoteRepo() : ''), ), ) - ->nonInteractively(fn () => $git->hasGitHubRemote() ? $git->remoteRepo() : null), + ->nonInteractively(fn () => $git->hasRemote() ? $git->remoteRepo() : null), ); + $sourceProvider = $this->resolveSourceProvider(); + $regions = spin( fn () => $this->client->meta()->regions(), 'Fetching regions...', @@ -107,6 +112,7 @@ protected function createApplication() name: $this->form()->get('name'), region: $this->form()->get('region'), rootDirectory: $rootDirectory === '' ? null : $rootDirectory, + sourceProvider: $sourceProvider, ), ), 'Creating application...', diff --git a/app/Commands/ApplicationGet.php b/app/Commands/ApplicationGet.php index f1451722..07273e64 100644 --- a/app/Commands/ApplicationGet.php +++ b/app/Commands/ApplicationGet.php @@ -3,6 +3,7 @@ namespace App\Commands; use App\Dto\Application; +use App\SourceProviders\SourceProviderManager; use function Laravel\Prompts\intro; @@ -26,11 +27,15 @@ public function handle() $this->outputJsonIfWanted($application); + $repository = $application->repositoryFullName; + dataList(array_filter([ 'ID' => $application->id, 'Name' => $application->name, 'Region' => $application->region, - 'Repository' => 'https://github.com/'.$application->repositoryFullName, + 'Repository' => $repository === null + ? null + : app(SourceProviderManager::class)->forRepository($repository)->repositoryUrl($repository), 'Root Directory' => $application->rootDirectory, 'Environments' => collect($application->environments)->map(fn ($env) => [$env->name, $env->id])->toArray(), 'Organization' => [ diff --git a/app/Commands/ApplicationUpdate.php b/app/Commands/ApplicationUpdate.php index 649f575d..a084f5f1 100644 --- a/app/Commands/ApplicationUpdate.php +++ b/app/Commands/ApplicationUpdate.php @@ -5,6 +5,7 @@ use App\Client\Requests\UpdateApplicationAvatarRequestData; use App\Client\Requests\UpdateApplicationRequestData; use App\Concerns\HandlesAvatars; +use App\Concerns\ResolvesSourceProvider; use App\Dto\Application; use App\Exceptions\CommandExitException; use App\Git; @@ -20,6 +21,7 @@ class ApplicationUpdate extends BaseCommand protected ?string $jsonDataClass = Application::class; use HandlesAvatars; + use ResolvesSourceProvider; protected $signature = 'application:update {application? : The application ID or name} @@ -27,6 +29,7 @@ class ApplicationUpdate extends BaseCommand {--slug= : Application slug} {--slack-channel= : Slack channel for notifications} {--repository= : Repository URL} + {--source-provider= : Source provider (github, gitlab, gitlab_self_hosted)} {--avatar= : Avatar URL or full path to a file} {--default-environment= : Default environment ID or name} {--force : Force update without confirmation}'; @@ -74,6 +77,7 @@ protected function updateApplication(Application $application): Application defaultEnvironmentId: $this->form()->get('default_environment_id'), repository: $this->form()->get('repository'), slackChannel: $this->form()->get('slack_channel'), + sourceProvider: $this->sourceProviderFrom($this->form()->get('source_provider')), ), ), 'Updating application...', @@ -137,6 +141,9 @@ protected function defineFields(Application $application): void ))->setPreviousValue($application->repositoryFullName), ); + // Not inferred from the local remote: the repository being set may not be this one. + $this->defineSourceProviderField(); + $this->form()->define( 'avatar', fn ($resolver) => $resolver->fromInput($this->getNewAvatar(...)), diff --git a/app/Commands/DeploymentGet.php b/app/Commands/DeploymentGet.php index 64e2d6f5..d7aabba8 100644 --- a/app/Commands/DeploymentGet.php +++ b/app/Commands/DeploymentGet.php @@ -3,7 +3,7 @@ namespace App\Commands; use App\Dto\Deployment; -use App\Git; +use App\SourceProviders\SourceProviderManager; use function Laravel\Prompts\intro; @@ -27,11 +27,14 @@ public function handle() $this->outputJsonIfWanted($deployment); + $repository = $environment->application->repositoryFullName; + $provider = app(SourceProviderManager::class)->forRepository($repository); + dataList([ 'ID' => $deployment->id, 'Status' => $deployment->status->label(), - 'Branch' => Git::branchUrl($environment->application->repositoryFullName, $deployment->branchName), - 'Commit' => Git::commitUrl($environment->application->repositoryFullName, $deployment->commitHash), + 'Branch' => $provider->branchUrl($repository, $deployment->branchName), + 'Commit' => $provider->commitUrl($repository, $deployment->commitHash), 'Message' => $deployment->commitMessage, 'Author' => $deployment->commitAuthor ?? '—', 'Started At' => $deployment->startedAt?->toIso8601String() ?? '—', diff --git a/app/Commands/Ship.php b/app/Commands/Ship.php index d8ee4e40..0a6a1730 100644 --- a/app/Commands/Ship.php +++ b/app/Commands/Ship.php @@ -13,6 +13,7 @@ use App\Concerns\CreatesWebSocketCluster; use App\Concerns\HandlesAvatars; use App\Concerns\RequiresRemoteGitRepo; +use App\Concerns\ResolvesSourceProvider; use App\Concerns\UpdatesBuildDeployCommands; use App\Dto\Application; use App\Dto\Database; @@ -54,6 +55,7 @@ class Ship extends BaseCommand use CreatesWebSocketCluster; use HandlesAvatars; use RequiresRemoteGitRepo; + use ResolvesSourceProvider; use UpdatesBuildDeployCommands; protected $signature = 'ship @@ -62,6 +64,7 @@ class Ship extends BaseCommand {--name= : Application name (non-interactive). Default: derived from repository} {--region= : Region (non-interactive). Default: most-used or us-east-2} {--root-directory= : Repository subdirectory containing the app (monorepos)} + {--source-provider= : Source provider (github, gitlab, gitlab_self_hosted). Default: detected from the origin remote} '; protected $description = 'Ship a new application to Laravel Cloud'; @@ -246,6 +249,7 @@ protected function createApplicationNonInteractively(string $repository, string name: $name, region: $region, rootDirectory: $this->rootDirectoryOption(), + sourceProvider: $this->resolveSourceProvider(), ); try { @@ -314,6 +318,7 @@ protected function createApplication(string $defaultRegion, string $repository): name: $this->form()->get('name'), region: $this->form()->get('region'), rootDirectory: $this->rootDirectoryOption(), + sourceProvider: $this->resolveSourceProvider(), ), ), ); diff --git a/app/Concerns/RequiresRemoteGitRepo.php b/app/Concerns/RequiresRemoteGitRepo.php index b4b56f8f..fe92500c 100644 --- a/app/Concerns/RequiresRemoteGitRepo.php +++ b/app/Concerns/RequiresRemoteGitRepo.php @@ -4,10 +4,13 @@ use App\Exceptions\CommandExitException; use App\Git; +use App\SourceProviders\CreatesRepositories; +use App\SourceProviders\SourceProviderManager; use RuntimeException; use function Laravel\Prompts\confirm; use function Laravel\Prompts\error; +use function Laravel\Prompts\info; use function Laravel\Prompts\select; use function Laravel\Prompts\text; use function Laravel\Prompts\warning; @@ -18,23 +21,26 @@ protected function ensureRemoteGitRepo(): void { $git = app(Git::class); - if ($git->hasGitHubRemote()) { + if ($git->hasRemote()) { return; } if (! $this->isInteractive()) { - throw new RuntimeException('This directory is not a Git repository. A Git repository is required to deploy to Laravel Cloud.'); + throw new RuntimeException('This directory has no Git remote. A Git repository is required to deploy to Laravel Cloud.'); } - if (! $git->ghInstalled() || ! $git->ghAuthenticated()) { - warning('This directory is not a Git repository. A Git repository is required to deploy to Laravel Cloud.'); + $creators = app(SourceProviderManager::class)->repositoryCreators(); + + if ($creators === []) { + warning('This directory has no Git remote. A Git repository is required to deploy to Laravel Cloud.'); + warning('Install and sign in to the GitHub CLI (gh) or the GitLab CLI (glab) to create one from here.'); throw new CommandExitException(1); } if ($git->isRepo()) { $createRepo = confirm( - label: 'No GitHub remote found. Would you like to create a GitHub repository?', + label: 'No Git remote found. Would you like to create a repository?', default: true, ); @@ -57,21 +63,9 @@ protected function ensureRemoteGitRepo(): void info('Git repository initialized.'); } - $username = $git->getGitHubUsername(); - $orgs = $git->getGitHubOrgs(); - - $owners = collect([$username])->merge($orgs)->filter()->mapWithKeys(fn ($org) => [$org => $org]); + $driver = $this->selectSourceProvider($creators); - if ($owners->count() === 1) { - $owner = $owners->first(); - info('Using GitHub account: '.$owner); - } else { - $owner = select( - label: 'Which GitHub account should own this repository?', - options: $owners, - default: $owners->first(), - ); - } + $owner = $this->selectRepositoryOwner($driver); $repoName = text( label: 'Repository name', @@ -88,7 +82,7 @@ protected function ensureRemoteGitRepo(): void default: 'private', ); - $result = $git->createGitHubRepo($repoName, $owner, $visibility === 'private'); + $result = $driver->createRepository($repoName, $owner, $visibility === 'private'); if (! $result->successful()) { error('Failed to create repository: '.$result->errorOutput()); @@ -96,7 +90,7 @@ protected function ensureRemoteGitRepo(): void throw new CommandExitException(1); } - info("Repository created: https://github.com/{$owner}/{$repoName}"); + info('Repository created: '.$driver->repositoryUrl($owner.'/'.$repoName)); $shouldCommit = confirm( label: 'Would you like to add, commit, and push your files?', @@ -133,6 +127,50 @@ protected function ensureRemoteGitRepo(): void throw new CommandExitException(1); } - info('Pushed to GitHub successfully.'); + info('Pushed to '.$driver->provider()->label().' successfully.'); + } + + /** + * @param array $creators + */ + protected function selectSourceProvider(array $creators): CreatesRepositories + { + if (count($creators) === 1) { + $driver = reset($creators); + + info('Using '.$driver->provider()->label().'.'); + + return $driver; + } + + return $creators[select( + label: 'Where should the repository be created?', + options: array_map(fn (CreatesRepositories $driver) => $driver->provider()->label(), $creators), + )]; + } + + protected function selectRepositoryOwner(CreatesRepositories $driver): string + { + $owners = $driver->owners(); + + if ($owners->isEmpty()) { + error('Could not read any accounts from '.$driver->cliName().'.'); + + throw new CommandExitException(1); + } + + if ($owners->count() === 1) { + $owner = $owners->first(); + + info('Using account: '.$owner); + + return $owner; + } + + return select( + label: 'Which account should own this repository?', + options: $owners->all(), + default: $owners->first(), + ); } } diff --git a/app/Concerns/ResolvesSourceProvider.php b/app/Concerns/ResolvesSourceProvider.php new file mode 100644 index 00000000..08886ee5 --- /dev/null +++ b/app/Concerns/ResolvesSourceProvider.php @@ -0,0 +1,64 @@ +defineSourceProviderField(); + + if ($given = $this->form()->get('source_provider')) { + return $this->sourceProviderFrom($given); + } + + if ($detected = app(SourceProviderManager::class)->detect()) { + return $detected; + } + + $this->form()->prompt('source_provider'); + + return $this->sourceProviderFrom($this->form()->get('source_provider')); + } + + protected function defineSourceProviderField(): void + { + $this->form()->define( + 'source_provider', + fn ($resolver) => $resolver + ->fromInput(fn (?string $value) => select( + label: 'Source provider', + options: SourceProvider::options(), + default: $value ?? SourceProvider::GITHUB->value, + )) + ->nonInteractively(fn () => SourceProvider::GITHUB->value), + ); + } + + protected function sourceProviderFrom(?string $value): ?SourceProvider + { + if ($value === null) { + return null; + } + + $provider = SourceProvider::tryFrom($value); + + if ($provider === null || ! $provider->supported()) { + $this->failAndExit( + 'Unknown source provider ['.$value.']. Use one of: ' + .implode(', ', array_keys(SourceProvider::options())), + ); + } + + return $provider; + } +} diff --git a/app/Enums/SourceProvider.php b/app/Enums/SourceProvider.php new file mode 100644 index 00000000..2349b283 --- /dev/null +++ b/app/Enums/SourceProvider.php @@ -0,0 +1,72 @@ + 'GitHub', + self::GITLAB => 'GitLab', + self::GITLAB_SELF_HOSTED => 'GitLab (self-hosted)', + self::BITBUCKET => 'Bitbucket', + }; + } + + /** + * Whether the CLI has a driver for this provider. Unsupported cases still exist so + * the enum matches the API, and so --source-provider fails with a real message. + */ + public function supported(): bool + { + return $this !== self::BITBUCKET; + } + + /** + * Remote hosts that identify this provider. Self-hosted and unsupported providers + * claim none, so detection never lands on them. + */ + public function hosts(): array + { + return match ($this) { + self::GITHUB => ['github.com'], + self::GITLAB => ['gitlab.com'], + self::GITLAB_SELF_HOSTED, self::BITBUCKET => [], + }; + } + + public static function fromHost(?string $host): ?self + { + if ($host === null) { + return null; + } + + foreach (self::cases() as $case) { + if (in_array($host, $case->hosts(), true)) { + return $case; + } + } + + return null; + } + + /** + * @return array + */ + public static function options(): array + { + return collect(self::cases()) + ->filter(fn (self $case) => $case->supported()) + ->mapWithKeys(fn (self $case) => [$case->value => $case->label()]) + ->all(); + } +} diff --git a/app/Exceptions/UnsupportedSourceProviderException.php b/app/Exceptions/UnsupportedSourceProviderException.php new file mode 100644 index 00000000..62ade4ec --- /dev/null +++ b/app/Exceptions/UnsupportedSourceProviderException.php @@ -0,0 +1,10 @@ +output()); } - public function hasGitHubRemote(): bool + public function hasRemote(): bool { - $result = $this->run(['git', 'remote', '-v']); - - if (! $result->successful()) { - return false; - } - - return Str::contains($result->output(), 'github.com'); + return $this->remoteUrl() !== null; } public function initRepo(): bool @@ -46,61 +39,45 @@ public function addRemote(string $name, string $url): bool return $this->run(['git', 'remote', 'add', $name, $url])->successful(); } - public function ghInstalled(): bool - { - return $this->run(['which', 'gh'])->successful(); - } - - public function ghAuthenticated(): bool + public function currentDirectoryName(): string { - return $this->run(['gh', 'auth', 'status'])->successful(); + return basename(getcwd()); } - public function getGitHubOrgs(): Collection + public function remoteRepo(): string { - $result = $this->run(['gh', 'api', 'user/orgs', '--jq', '.[].login']); + $url = $this->remoteUrl(); - if (! $result->successful()) { - return collect(); + if ($url === null) { + return ''; } - return collect(array_filter(explode("\n", trim($result->output())))); + $path = Str::of($url)->contains('://') + ? Str::of($url)->after('://')->after('/') + : Str::of($url)->after(':'); + + return $path->beforeLast('.git')->toString(); } - public function getGitHubUsername(): ?string + /** + * The host the origin remote points at, which is how we tell one provider from another. + */ + public function remoteHost(): ?string { - $result = $this->run(['gh', 'api', 'user', '--jq', '.login']); + $url = $this->remoteUrl(); - if (! $result->successful()) { + if ($url === null) { return null; } - return trim($result->output()); - } - - public function createGitHubRepo(string $name, string $org, bool $private): ProcessResult - { - $visibility = $private ? '--private' : '--public'; + $host = Str::of($url)->contains('://') + ? Str::of($url)->after('://')->after('@')->before('/') + : Str::of($url)->before(':')->afterLast('@'); - $repoName = $org.'/'.$name; + // Ports are not part of the host we match on. + $host = $host->before(':')->lower()->toString(); - return $this->run(['gh', 'repo', 'create', $repoName, $visibility, '--source', '.', '--remote', 'origin']); - } - - public function currentDirectoryName(): string - { - return basename(getcwd()); - } - - public function remoteRepo(): string - { - $url = str($this->run(['git', 'remote', 'get-url', 'origin'])->output())->trim(); - - $repo = $url->contains('://') - ? $url->after('://')->after('/') - : $url->after(':'); - - return $repo->beforeLast('.git')->toString(); + return $host === '' ? null : $host; } public function addAll(): bool @@ -146,14 +123,17 @@ public function currentBranch(): string return $this->getDefaultBranch(); } - public static function commitUrl(string $repositoryFullName, string $commitHash): string + protected function remoteUrl(): ?string { - return sprintf('https://github.com/%s/commit/%s', $repositoryFullName, $commitHash); - } + $result = $this->run(['git', 'remote', 'get-url', 'origin']); - public static function branchUrl(string $repositoryFullName, string $branchName): string - { - return sprintf('https://github.com/%s/tree/%s', $repositoryFullName, $branchName); + if (! $result->successful()) { + return null; + } + + $url = trim($result->output()); + + return $url === '' ? null : $url; } protected function run(array $command): ProcessResult diff --git a/app/SourceProviders/Concerns/RunsCli.php b/app/SourceProviders/Concerns/RunsCli.php new file mode 100644 index 00000000..07403a1a --- /dev/null +++ b/app/SourceProviders/Concerns/RunsCli.php @@ -0,0 +1,25 @@ +run($command); + + if (! $result->successful()) { + return []; + } + + return json_decode(trim($result->output()), true) ?: []; + } +} diff --git a/app/SourceProviders/CreatesRepositories.php b/app/SourceProviders/CreatesRepositories.php new file mode 100644 index 00000000..0eca30df --- /dev/null +++ b/app/SourceProviders/CreatesRepositories.php @@ -0,0 +1,27 @@ + + */ + public function owners(): Collection; + + public function createRepository(string $name, string $owner, bool $private): ProcessResult; +} diff --git a/app/SourceProviders/GitHubProvider.php b/app/SourceProviders/GitHubProvider.php new file mode 100644 index 00000000..b0af350d --- /dev/null +++ b/app/SourceProviders/GitHubProvider.php @@ -0,0 +1,74 @@ +baseUrl().'/'.$repository; + } + + public function commitUrl(string $repository, string $commitHash): string + { + return $this->repositoryUrl($repository).'/commit/'.$commitHash; + } + + public function branchUrl(string $repository, string $branchName): string + { + return $this->repositoryUrl($repository).'/tree/'.$branchName; + } + + public function cliName(): string + { + return 'gh'; + } + + public function cliInstalled(): bool + { + return $this->run(['which', 'gh'])->successful(); + } + + public function cliAuthenticated(): bool + { + return $this->run(['gh', 'auth', 'status'])->successful(); + } + + public function owners(): Collection + { + $user = $this->json(['gh', 'api', 'user']); + $orgs = $this->json(['gh', 'api', 'user/orgs', '--paginate']); + + return collect([$user['login'] ?? null]) + ->merge(array_column($orgs, 'login')) + ->filter() + ->mapWithKeys(fn (string $owner) => [$owner => $owner]); + } + + public function createRepository(string $name, string $owner, bool $private): ProcessResult + { + return $this->run([ + 'gh', 'repo', 'create', $owner.'/'.$name, + $private ? '--private' : '--public', + '--source', '.', + '--remote', 'origin', + ]); + } +} diff --git a/app/SourceProviders/GitLabProvider.php b/app/SourceProviders/GitLabProvider.php new file mode 100644 index 00000000..1e49258a --- /dev/null +++ b/app/SourceProviders/GitLabProvider.php @@ -0,0 +1,90 @@ +baseUrl().'/'.$repository; + } + + public function commitUrl(string $repository, string $commitHash): string + { + return $this->repositoryUrl($repository).'/-/commit/'.$commitHash; + } + + public function branchUrl(string $repository, string $branchName): string + { + return $this->repositoryUrl($repository).'/-/tree/'.$branchName; + } + + public function cliName(): string + { + return 'glab'; + } + + public function cliInstalled(): bool + { + return $this->run(['which', 'glab'])->successful(); + } + + public function cliAuthenticated(): bool + { + return $this->run(['glab', 'auth', 'status', ...$this->hostArgs()])->successful(); + } + + public function owners(): Collection + { + $user = $this->json(['glab', 'api', 'user', ...$this->hostArgs()]); + + // Developer is the lowest role that can be granted project creation in a group. + $groups = $this->json(['glab', 'api', 'groups?min_access_level=30', '--paginate', ...$this->hostArgs()]); + + return collect([$user['username'] ?? null]) + ->merge(array_column($groups, 'full_path')) + ->filter() + ->mapWithKeys(fn (string $owner) => [$owner => $owner]); + } + + public function createRepository(string $name, string $owner, bool $private): ProcessResult + { + return $this->run([ + 'glab', 'repo', 'create', $this->projectPath($owner, $name), + // GitLab projects default to internal, so visibility is always explicit. + $private ? '--private' : '--public', + '--remoteName', 'origin', + ]); + } + + protected function projectPath(string $owner, string $name): string + { + return $owner.'/'.$name; + } + + /** + * `glab` picks its host from the current directory's remote, which is no help before + * a remote exists. Only a self-hosted instance needs to say which one it means. + */ + protected function hostArgs(): array + { + return []; + } +} diff --git a/app/SourceProviders/GitLabSelfHostedProvider.php b/app/SourceProviders/GitLabSelfHostedProvider.php new file mode 100644 index 00000000..af3d59b9 --- /dev/null +++ b/app/SourceProviders/GitLabSelfHostedProvider.php @@ -0,0 +1,36 @@ +host; + } + + /** + * `glab repo create` takes no --hostname, so the host goes in the project path. + */ + protected function projectPath(string $owner, string $name): string + { + return $this->host.'/'.$owner.'/'.$name; + } + + protected function hostArgs(): array + { + return ['--hostname', $this->host]; + } +} diff --git a/app/SourceProviders/SourceProviderDriver.php b/app/SourceProviders/SourceProviderDriver.php new file mode 100644 index 00000000..2fe1ea10 --- /dev/null +++ b/app/SourceProviders/SourceProviderDriver.php @@ -0,0 +1,22 @@ + new GitHubProvider, + SourceProvider::GITLAB => new GitLabProvider, + SourceProvider::GITLAB_SELF_HOSTED => new GitLabSelfHostedProvider( + $host ?? $this->git->remoteHost() ?? throw new UnsupportedSourceProviderException( + 'Could not work out which GitLab instance this repository lives on.', + ), + ), + SourceProvider::BITBUCKET => throw new UnsupportedSourceProviderException( + $provider->label().' is not supported by the CLI yet.', + ), + }; + } + + /** + * The provider behind the current directory's origin remote, if we recognise the host. + */ + public function detect(): ?SourceProvider + { + return SourceProvider::fromHost($this->git->remoteHost()); + } + + /** + * The API does not tell us which provider an application uses, so the only hint we + * have is the remote in the current directory. An application belonging to some + * other repository falls back to GitHub and may well be wrong. + * + * Delete this once the repository attributes carry the provider type. + */ + public function forRepository(?string $repositoryFullName): SourceProviderDriver + { + $provider = $repositoryFullName !== null && $repositoryFullName === $this->git->remoteRepo() + ? $this->detect() + : null; + + return $this->driver($provider ?? SourceProvider::GITHUB); + } + + /** + * Providers that can create a repository right now. Self-hosted GitLab is missing on + * purpose: with no remote yet, there is no host to point `glab` at. + * + * @return array + */ + public function repositoryCreators(): array + { + return collect([new GitHubProvider, new GitLabProvider]) + ->filter(fn (CreatesRepositories $driver) => $driver->cliInstalled() && $driver->cliAuthenticated()) + ->keyBy(fn (CreatesRepositories $driver) => $driver->provider()->value) + ->all(); + } +} diff --git a/tests/Feature/ApplicationCreateRootDirectoryTest.php b/tests/Feature/ApplicationCreateRootDirectoryTest.php index 90733fb6..0eb37c6a 100644 --- a/tests/Feature/ApplicationCreateRootDirectoryTest.php +++ b/tests/Feature/ApplicationCreateRootDirectoryTest.php @@ -14,7 +14,8 @@ $this->mockGit = Mockery::mock(Git::class); $this->mockGit->shouldReceive('isRepo')->andReturn(true)->byDefault(); $this->mockGit->shouldReceive('getRoot')->andReturn('/tmp/test-repo')->byDefault(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('remoteHost')->andReturn('github.com')->byDefault(); $this->mockGit->shouldReceive('remoteRepo')->andReturn('laravel/cloud-cli')->byDefault(); $this->app->instance(Git::class, $this->mockGit); diff --git a/tests/Feature/DeployTest.php b/tests/Feature/DeployTest.php index a26fff7a..bd4faee4 100644 --- a/tests/Feature/DeployTest.php +++ b/tests/Feature/DeployTest.php @@ -83,7 +83,7 @@ function setupSuccessfulDeployMocks(): void it('deploys an application successfully when one app and one environment exist', function () { Prompt::fake(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app'); setupSuccessfulDeployMocks(); @@ -95,7 +95,7 @@ function setupSuccessfulDeployMocks(): void it('deploys using explicit application and environment arguments', function () { Prompt::fake(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app'); setupSuccessfulDeployMocks(); @@ -109,7 +109,7 @@ function setupSuccessfulDeployMocks(): void it('selects application when given by name argument with multiple apps', function () { Prompt::fake(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app'); MockClient::global([ @@ -213,7 +213,7 @@ function setupSuccessfulDeployMocks(): void it('selects environment by name when multiple environments exist', function () { Prompt::fake(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app'); MockClient::global([ @@ -306,7 +306,7 @@ function setupSuccessfulDeployMocks(): void it('deploys to specific application by ID', function () { Prompt::fake(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app'); setupSuccessfulDeployMocks(); diff --git a/tests/Feature/ShipRootDirectoryTest.php b/tests/Feature/ShipRootDirectoryTest.php index 3f9cc9ea..04bd87fc 100644 --- a/tests/Feature/ShipRootDirectoryTest.php +++ b/tests/Feature/ShipRootDirectoryTest.php @@ -16,7 +16,8 @@ $this->mockGit = Mockery::mock(Git::class); $this->mockGit->shouldReceive('isRepo')->andReturn(true)->byDefault(); $this->mockGit->shouldReceive('getRoot')->andReturn('/tmp/test-repo')->byDefault(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('remoteHost')->andReturn('github.com')->byDefault(); $this->mockGit->shouldReceive('remoteRepo')->andReturn('user/my-app')->byDefault(); $this->app->instance(Git::class, $this->mockGit); diff --git a/tests/Feature/SourceProviderCommandsTest.php b/tests/Feature/SourceProviderCommandsTest.php new file mode 100644 index 00000000..d405655f --- /dev/null +++ b/tests/Feature/SourceProviderCommandsTest.php @@ -0,0 +1,96 @@ +mockGit = Mockery::mock(Git::class); + $this->mockGit->shouldReceive('isRepo')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('getRoot')->andReturn('/tmp/test-repo')->byDefault(); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('remoteRepo')->andReturn('group/my-app')->byDefault(); + $this->app->instance(Git::class, $this->mockGit); + + $this->mockConfig = Mockery::mock(ConfigRepository::class); + $this->mockConfig->shouldReceive('apiTokens')->andReturn(collect(['test-api-token'])); + $this->app->instance(ConfigRepository::class, $this->mockConfig); + + MockClient::global([ + GetOrganizationRequest::class => MockResponse::make(organizationResponse(), 200), + ListRegionsRequest::class => MockResponse::make(regionsResponse(), 200), + CreateApplicationRequest::class => MockResponse::make([ + 'data' => createApplicationResponse(), + 'included' => [ + ['id' => 'org-1', 'type' => 'organizations', 'attributes' => ['name' => 'My Org', 'slug' => 'my-org']], + ], + ], 200), + ]); +}); + +afterEach(function () { + MockClient::destroyGlobal(); +}); + +function createApplicationWith(array $options = []): PendingCommand +{ + return test()->artisan('application:create', array_merge([ + '--name' => 'My App', + '--repository' => 'group/my-app', + '--region' => 'us-east-1', + '--no-interaction' => true, + ], $options)); +} + +function sentSourceProvider(): ?string +{ + $sent = null; + + MockClient::global()->assertSent(function ($request) use (&$sent) { + if ($request instanceof CreateApplicationRequest) { + $sent = $request->body()->all()['source_control_provider_type'] ?? null; + } + + return true; + }); + + return $sent; +} + +it('sends the provider the origin remote points at', function (string $host, string $expected) { + $this->mockGit->shouldReceive('remoteHost')->andReturn($host); + + createApplicationWith()->assertSuccessful(); + + expect(sentSourceProvider())->toBe($expected); +})->with([ + 'GitHub' => ['github.com', 'github'], + 'GitLab' => ['gitlab.com', 'gitlab'], +]); + +it('falls back to GitHub when the remote host is not one it knows', function () { + $this->mockGit->shouldReceive('remoteHost')->andReturn('git.example.com'); + + createApplicationWith()->assertSuccessful(); + + expect(sentSourceProvider())->toBe('github'); +}); + +it('prefers an explicit --source-provider over the remote', function () { + $this->mockGit->shouldReceive('remoteHost')->andReturn('github.com'); + + createApplicationWith(['--source-provider' => 'gitlab_self_hosted'])->assertSuccessful(); + + expect(sentSourceProvider())->toBe('gitlab_self_hosted'); +}); + +it('rejects a provider it has no driver for', function (string $provider) { + $this->mockGit->shouldReceive('remoteHost')->andReturn('github.com'); + + createApplicationWith(['--source-provider' => $provider])->assertFailed(); +})->with(['bitbucket', 'not-a-provider']); diff --git a/tests/Feature/UnreadableResponseTest.php b/tests/Feature/UnreadableResponseTest.php index 31501d50..1911ca56 100644 --- a/tests/Feature/UnreadableResponseTest.php +++ b/tests/Feature/UnreadableResponseTest.php @@ -12,7 +12,8 @@ $this->mockGit = Mockery::mock(Git::class); $this->mockGit->shouldReceive('isRepo')->andReturn(true)->byDefault(); $this->mockGit->shouldReceive('getRoot')->andReturn('/tmp/test-repo')->byDefault(); - $this->mockGit->shouldReceive('hasGitHubRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('hasRemote')->andReturn(true)->byDefault(); + $this->mockGit->shouldReceive('remoteHost')->andReturn('github.com')->byDefault(); $this->mockGit->shouldReceive('remoteRepo')->andReturn('laravel/cloud-cli')->byDefault(); $this->app->instance(Git::class, $this->mockGit); diff --git a/tests/Unit/GitLabProviderTest.php b/tests/Unit/GitLabProviderTest.php new file mode 100644 index 00000000..a53f2b8d --- /dev/null +++ b/tests/Unit/GitLabProviderTest.php @@ -0,0 +1,54 @@ + Process::result(json_encode(['username' => 'jane'])), + '*glab*api*groups*' => Process::result(json_encode([ + ['full_path' => 'acme'], + ['full_path' => 'acme/platform'], + ])), + ]); + + expect((new GitLabProvider)->owners()->all())->toBe([ + 'jane' => 'jane', + 'acme' => 'acme', + 'acme/platform' => 'acme/platform', + ]); +}); + +it('returns no owners when glab fails', function () { + Process::fake(fn () => Process::result('', 'not logged in', 1)); + + expect((new GitLabProvider)->owners())->toBeEmpty(); +}); + +it('always states visibility, since GitLab projects default to internal', function () { + Process::fake(); + + (new GitLabProvider)->createRepository('my-app', 'acme', private: true); + + Process::assertRan(fn ($process) => $process->command === [ + 'glab', 'repo', 'create', 'acme/my-app', '--private', '--remoteName', 'origin', + ]); +}); + +it('points glab at the instance a self-hosted repository lives on', function () { + Process::fake(); + + $provider = new GitLabSelfHostedProvider('git.example.com'); + $provider->cliAuthenticated(); + $provider->createRepository('my-app', 'acme', private: false); + + Process::assertRan(fn ($process) => $process->command === [ + 'glab', 'auth', 'status', '--hostname', 'git.example.com', + ]); + + // `glab repo create` takes no --hostname, so the host has to lead the project path. + Process::assertRan(fn ($process) => $process->command === [ + 'glab', 'repo', 'create', 'git.example.com/acme/my-app', '--public', '--remoteName', 'origin', + ]); +}); diff --git a/tests/Unit/SourceProviderTest.php b/tests/Unit/SourceProviderTest.php new file mode 100644 index 00000000..f97e9a6c --- /dev/null +++ b/tests/Unit/SourceProviderTest.php @@ -0,0 +1,67 @@ +toBe($expected); +})->with([ + 'GitHub' => ['github.com', SourceProvider::GITHUB], + 'GitLab' => ['gitlab.com', SourceProvider::GITLAB], + 'self-hosted' => ['git.example.com', null], + 'Bitbucket has no driver yet' => ['bitbucket.org', null], + 'no remote' => [null, null], +]); + +it('offers only providers it has a driver for', function () { + expect(SourceProvider::options())->not->toHaveKey('bitbucket') + ->and(SourceProvider::options())->toHaveKeys(['github', 'gitlab', 'gitlab_self_hosted']); +}); + +it('builds commit and branch URLs per provider', function () { + $github = new GitHubProvider; + $gitlab = new GitLabProvider; + $selfHosted = new GitLabSelfHostedProvider('git.example.com'); + + expect($github->commitUrl('user/repo', 'abc123'))->toBe('https://github.com/user/repo/commit/abc123') + ->and($github->branchUrl('user/repo', 'main'))->toBe('https://github.com/user/repo/tree/main') + ->and($gitlab->commitUrl('group/repo', 'abc123'))->toBe('https://gitlab.com/group/repo/-/commit/abc123') + ->and($gitlab->branchUrl('group/repo', 'main'))->toBe('https://gitlab.com/group/repo/-/tree/main') + ->and($selfHosted->commitUrl('group/sub/repo', 'abc123'))->toBe('https://git.example.com/group/sub/repo/-/commit/abc123') + ->and($selfHosted->repositoryUrl('group/sub/repo'))->toBe('https://git.example.com/group/sub/repo'); +}); + +it('resolves a driver for every supported provider', function (SourceProvider $provider) { + $manager = new SourceProviderManager(app(Git::class)); + + expect($manager->driver($provider, 'git.example.com')->provider())->toBe($provider); +})->with(fn () => collect(SourceProvider::cases())->filter->supported()->all()); + +it('refuses to build a driver for a provider it does not support', function () { + (new SourceProviderManager(app(Git::class)))->driver(SourceProvider::BITBUCKET); +})->throws(UnsupportedSourceProviderException::class, 'Bitbucket is not supported by the CLI yet.'); + +it('falls back to GitHub when the application is not the repository we are sitting in', function () { + $git = Mockery::mock(Git::class); + $git->shouldReceive('remoteRepo')->andReturn('group/some-other-repo'); + $git->shouldReceive('remoteHost')->andReturn('gitlab.com'); + + $driver = (new SourceProviderManager($git))->forRepository('user/an-app'); + + expect($driver->provider())->toBe(SourceProvider::GITHUB); +}); + +it('uses the local remote when the application is the repository we are sitting in', function () { + $git = Mockery::mock(Git::class); + $git->shouldReceive('remoteRepo')->andReturn('group/my-app'); + $git->shouldReceive('remoteHost')->andReturn('gitlab.com'); + + $driver = (new SourceProviderManager($git))->forRepository('group/my-app'); + + expect($driver->provider())->toBe(SourceProvider::GITLAB); +});