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
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ on:
pull_request:

jobs:
phpstan:
name: PHPStan - PHP ${{ matrix.php }} ${{ matrix.dependency-version }}
tests:
name: Tests - PHP ${{ matrix.php }} ${{ matrix.dependency-version }}
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.1', '8.2', '8.3', '8.4' ]
php: [ '8.2', '8.3', '8.4' ]
dependency-version: [ '' ]
include:
- php: '8.1'
- php: '8.2'
dependency-version: '--prefer-lowest'
steps:
- name: Checkout
Expand All @@ -39,3 +39,6 @@ jobs:

- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress

- name: PHPUnit
run: vendor/bin/phpunit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vendor
.serverless
composer.lock
.phpunit.result.cache
11 changes: 9 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@
"Bref\\Cli\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Bref\\Cli\\Test\\": "tests/"
}
},
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-zip": "*",
"amphp/amp": "^3.0",
"amphp/file": "^3.2 || ^4.0",
"amphp/http-client": "^5.3",
"amphp/process": "^2.0",
"aws/aws-sdk-php": "^3.319",
"laravel/agent-detector": "^2.0.1",
"psy/psysh": "^0.12.0",
"revolt/event-loop": "^1.0",
"symfony/console": "^5.2 || ^6.2 || ^7 || ^8",
Expand All @@ -28,7 +34,8 @@
"symfony/yaml": "^5.2 || ^6.2 || ^7 || ^8"
},
"require-dev": {
"phpstan/phpstan": "^2"
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^11.5"
},
"config": {
"sort-packages": true
Expand Down
13 changes: 13 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
48 changes: 31 additions & 17 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public function __construct()
$this->safeAddCommand(new Commands\Cloud);
$this->safeAddCommand(new Commands\Tinker);
$this->safeAddCommand(new Commands\SecretCreate);
$this->safeAddCommand(new Commands\Logs);
$this->safeAddCommand(new Commands\Deployments);
$this->safeAddCommand(new Commands\DeploymentsShow);
$this->safeAddCommand(new Commands\DeploymentsLogs);
}

public function safeAddCommand(Command $command): ?Command
Expand Down Expand Up @@ -65,23 +69,7 @@ public function renderThrowable(Throwable $e, OutputInterface $output): void
{
IO::spinClear();

// Prettify Bref Cloud errors
if ($e instanceof ClientException) {
try {
$body = $e->getResponse()->toArray(false);
$message = $body['message'] ?? 'Unknown Bref Cloud error';
$statusCode = $e->getResponse()->getStatusCode();

$message = match ($statusCode) {
401 => 'Unauthenticated. Please log in with `bref login`.',
403 => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?',
default => $message,
};

$e = new Exception("Bref Cloud API error: [$statusCode] $message", $statusCode);
} catch (Throwable) {
}
}
$e = self::prettifyException($e);

// Prettify AWS credentials errors
if ($e instanceof CredentialsException && str_contains($e->getMessage(), 'not found in credentials file')) {
Expand All @@ -95,6 +83,32 @@ public function renderThrowable(Throwable $e, OutputInterface $output): void
IO::error($e);
}

/**
* Turn Bref Cloud API errors into their message.
*/
public static function prettifyException(Throwable $e): Throwable
{
if (! $e instanceof ClientException) {
return $e;
}
try {
$body = $e->getResponse()->toArray(false);
$message = $body['message'] ?? 'Unknown Bref Cloud error';
$statusCode = $e->getResponse()->getStatusCode();

$message = match ($statusCode) {
401 => 'Unauthenticated. Please log in with `bref login`.',
403 => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?',
429 => 'Too many requests, try again in a minute.',
default => $message,
};

return new Exception("Bref Cloud API error: [$statusCode] $message", $statusCode);
} catch (Throwable) {
return $e;
}
}

private function turnWarningsIntoExceptions(): void
{
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
Expand Down
73 changes: 70 additions & 3 deletions src/BrefCloudClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,27 @@

namespace Bref\Cli;

use Bref\Cli\Cli\LogRenderer;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @phpstan-import-type LogRecord from LogRenderer
* @phpstan-type Deployment array{
* id: int,
* status: string,
* message: string,
* error_message: string|null,
* git_ref: string|null,
* git_message: string|null,
* author: string|null,
* created_at: string|null,
* finished_at: string|null,
* url: string,
* }
*/
class BrefCloudClient
{
private const PRODUCTION_URL = 'https://bref.cloud';
Expand Down Expand Up @@ -37,9 +53,16 @@ class BrefCloudClient
public readonly string $url;
private HttpClientInterface $client;

public function __construct(?string $token = null)
/**
* @param HttpClientInterface|null $client Replaces the HTTP client (and the token), for tests.
*/
public function __construct(?string $token = null, ?HttpClientInterface $client = null)
{
$this->url = self::getUrl();
if ($client) {
$this->client = $client;
return;
}
if ($token === null) {
$token = Token::getToken($this->url);
}
Expand Down Expand Up @@ -122,27 +145,71 @@ public function startDeployment(int $deploymentId): void

/**
* @return array{
* deploymentId: int,
* status: string,
* message: string,
* error_message: string|null,
* url: string,
* app_url: string|null,
* logs: list<array{line: string, timestamp: int}>,
* outputs?: array<string, string>,
* id?: int,
* git_ref?: string|null,
* git_message?: string|null,
* author?: string|null,
* created_at?: string|null,
* finished_at?: string|null,
* environment?: array{id: int, name: string},
* app?: array{id: int, name: string},
* }
* The keys that are optional were added to Bref Cloud later.
*
* @throws HttpExceptionInterface
* @throws ExceptionInterface
*/
public function getDeployment(int $deploymentId): array
{
/** @var array{deploymentId: int, status: string, message: string, error_message: string|null, url: string, app_url: string|null, logs: list<array{line: string, timestamp: int}>, outputs?: array<string, string>} $result */
/** @var array{status: string, message: string, error_message: string|null, url: string, app_url: string|null, logs: list<array{line: string, timestamp: int}>, outputs?: array<string, string>, id?: int, git_ref?: string|null, git_message?: string|null, author?: string|null, created_at?: string|null, finished_at?: string|null, environment?: array{id: int, name: string}, app?: array{id: int, name: string}} $result */
$result = $this->client->request('GET', "/api/v1/deployments/$deploymentId")->toArray();

return $result;
}

/**
* @return list<Deployment> The most recent first.
*
* @throws HttpExceptionInterface
* @throws ExceptionInterface
*/
public function listDeployments(int $environmentId, int $limit): array
{
/** @var list<Deployment> $result */
$result = $this->client->request('GET', "/api/v1/environments/$environmentId/deployments", [
'query' => ['limit' => $limit],
])->toArray();

return $result;
}

/**
* @param array{since?: int, until?: int, search?: string, regex?: bool, functions?: list<string>, limit?: int, all?: bool, full?: bool} $query
* @return array{from: string, to: string, limit: int, has_more: bool, records: list<LogRecord>}
*
* @throws HttpExceptionInterface
* @throws ExceptionInterface
*/
public function getLogs(int $environmentId, array $query): array
{
/** @var array{from: string, to: string, limit: int, has_more: bool, records: list<LogRecord>} $result */
$result = $this->client->request('GET', "/api/v1/environments/$environmentId/logs", [
// Booleans are sent as 0/1
'query' => array_map(fn($value) => is_bool($value) ? (int) $value : $value, $query),
// Searching logs takes several seconds, and up to Bref Cloud's own timeout
'timeout' => 40,
])->toArray();

return $result;
}

public function pushDeploymentLogs(int $deploymentId, string $newLogs): void
{
$this->client->request('POST', "/api/v1/deployments/$deploymentId/logs", [
Expand Down
Loading
Loading