Skip to content
Draft
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
69 changes: 23 additions & 46 deletions src/Plugins/Tia/BaselineSync.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ private function renderChild(string $text): void

public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
$repo = $this->detectRepository($projectRoot);

if ($repo === null) {
if (! $repo instanceof GitHubRepository) {
return false;
}

Expand Down Expand Up @@ -181,47 +181,23 @@ private function isCi(): bool
|| getenv('CIRCLECI') === 'true';
}

private function detectGitHubRepo(string $projectRoot): ?string
private function detectRepository(string $projectRoot): ?GitHubRepository
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
$repo = GitHubRepository::fromProjectRoot($projectRoot);

if (! is_file($gitConfig)) {
return null;
}

$content = @file_get_contents($gitConfig);

if ($content === false) {
return null;
}

if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}

$url = $match[1];

if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}

if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}

if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
if (! $repo instanceof GitHubRepository || $repo->isDefaultHost()) {
return $repo;
}

return null;
return $this->ghAuthenticated($repo) ? $repo : null;
}

/**
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
private function download(GitHubRepository $repo, string $projectRoot, bool $hasAnchor = false): array
{
$this->validateGhDependencies($hasAnchor);
$this->validateGhDependencies($repo, $hasAnchor);

[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);

Expand All @@ -247,7 +223,7 @@ private function download(string $repo, string $projectRoot, bool $hasAnchor = f

$this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$repo->qualifiedName(),
$runId,
));

Expand Down Expand Up @@ -287,7 +263,7 @@ private function panicOnClassifiedError(array $diagnosis, string $contextPrefix,
));
}

private function validateGhDependencies(bool $hasAnchor): void
private function validateGhDependencies(GitHubRepository $repo, bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
Expand All @@ -297,7 +273,7 @@ private function validateGhDependencies(bool $hasAnchor): void
));
}

if (! $this->ghAuthenticated()) {
if (! $this->ghAuthenticated($repo)) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
Expand All @@ -309,7 +285,7 @@ private function validateGhDependencies(bool $hasAnchor): void
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
private function downloadArtifact(GitHubRepository $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);

Expand All @@ -318,16 +294,16 @@ private function downloadArtifact(string $repo, string $runId, string $runCacheD
? sprintf(
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
$repo->qualifiedName(),
)
: sprintf(
'Downloading TIA baseline from %s…',
$repo,
$repo->qualifiedName(),
));

$process = new Process([
'gh', 'run', 'download', $runId,
'-R', $repo,
'-R', $repo->qualifiedName(),
'-n', self::ARTIFACT_NAME,
'-D', $runCacheDir,
]);
Expand Down Expand Up @@ -383,11 +359,12 @@ private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor
return $payload;
}

private function artifactSize(string $repo, string $runId): ?int
private function artifactSize(GitHubRepository $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
...$repo->hostnameArguments(),
sprintf('repos/%s/actions/runs/%s/artifacts', $repo->name, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
Expand Down Expand Up @@ -529,11 +506,11 @@ private function trimDownloadCache(string $projectRoot): void
/**
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunIdWithError(string $repo): array
private function latestSuccessfulRunIdWithError(GitHubRepository $repo): array
{
$process = new Process([
'gh', 'run', 'list',
'-R', $repo,
'-R', $repo->qualifiedName(),
'--workflow', $this->workflowFile(),
'--status', 'success',
'--limit', '1',
Expand All @@ -552,9 +529,9 @@ private function latestSuccessfulRunIdWithError(string $repo): array
return [$runId === '' ? null : $runId, null];
}

private function ghAuthenticated(): bool
private function ghAuthenticated(GitHubRepository $repo): bool
{
$process = new Process(['gh', 'auth', 'status']);
$process = new Process(['gh', 'auth', 'status', ...$repo->hostnameArguments()]);
$process->setTimeout(10.0);
$process->run();

Expand Down
72 changes: 72 additions & 0 deletions src/Plugins/Tia/GitHubRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Pest\Plugins\Tia;

/**
* @internal
*/
final readonly class GitHubRepository
{
public const string DEFAULT_HOST = 'github.com';

private function __construct(
public string $host,
public string $name,
) {}

public static function fromProjectRoot(string $projectRoot): ?self
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';

if (! is_file($gitConfig)) {
return null;
}

$content = @file_get_contents($gitConfig);

if ($content === false) {
return null;
}

if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}

return self::fromRemoteUrl($match[1]);
}

public static function fromRemoteUrl(string $url): ?self
{
// user@host:owner/repo(.git)
if (preg_match('#^[\w.-]+@([\w.-]+):([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return new self(strtolower($m[1]), $m[2]);
}

// scheme://[user@]host[:port]/owner/repo(.git) — https, ssh
if (preg_match('#^(?:https?|ssh)://(?:[^@/]+@)?([\w.-]+)(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return new self(strtolower($m[1]), $m[2]);
}

return null;
}

public function isDefaultHost(): bool
{
return $this->host === self::DEFAULT_HOST;
}

public function qualifiedName(): string
{
return $this->isDefaultHost() ? $this->name : $this->host.'/'.$this->name;
}

/**
* @return array<int, string>
*/
public function hostnameArguments(): array
{
return $this->isDefaultHost() ? [] : ['--hostname', $this->host];
}
}
52 changes: 48 additions & 4 deletions tests/Features/Tia/RemoteBaseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use Pest\Plugins\Tia\GitHubRepository;
use Tests\Fixtures\Tia\Project;

afterEach(function (): void {
Expand All @@ -12,9 +13,18 @@
* @param callable(array<string, mixed>): array<string, mixed>|null $mutator
* @return array{0: Project, 1: array<string, string>}
*/
function tiaPublishedBaseline(string $mode = 'ok', ?callable $mutator = null): array
{
function tiaPublishedBaseline(
string $mode = 'ok',
?callable $mutator = null,
?string $origin = null,
string $host = GitHubRepository::DEFAULT_HOST,
): array {
$project = Project::make('master');

if ($origin !== null) {
$project->origin($origin);
}

$project->seed('master');

$payload = $project->detachGraph();
Expand All @@ -25,7 +35,7 @@ function tiaPublishedBaseline(string $mode = 'ok', ?callable $mutator = null): a
$payload = (string) json_encode($mutator($decoded), JSON_UNESCAPED_SLASHES);
}

return [$project, $project->gh($mode, $payload)];
return [$project, $project->gh($mode, $payload, $host)];
}

test('a published baseline is fetched instead of recorded locally', function (): void {
Expand All @@ -36,9 +46,43 @@ function tiaPublishedBaseline(string $mode = 'ok', ?callable $mutator = null): a
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('Downloading TIA baseline')
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($project->graphExists())->toBeTrue();
->and($project->graphExists())->toBeTrue()
->and($project->ghArgv())->toContain('-R pestphp/tia-fixture')
->and($project->ghArgv())->not->toContain('--hostname');
})->skipOnWindows();

test('a published baseline is fetched from a GitHub Enterprise Server remote', function (): void {
[$project, $environment] = tiaPublishedBaseline(
origin: 'git@github.foodics.com:pay/capital-api.git',
host: 'github.foodics.com',
);

$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');

expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('Downloading TIA baseline')
->and($result->output)->toContain('github.foodics.com/pay/capital-api')
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($project->graphExists())->toBeTrue()
->and($project->ghArgv())->toContain('-R github.foodics.com/pay/capital-api')
->and($project->ghArgv())->toContain('auth status --hostname github.foodics.com')
->and($project->ghArgv())->toContain('api --hostname github.foodics.com repos/pay/capital-api/actions/runs/');
})->skipOnWindows();

test('a remote on a host gh is not authenticated for records locally', function (string $origin): void {
[$project, $environment] = tiaPublishedBaseline(origin: $origin);

$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');

expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('Downloading TIA baseline')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($project->ghArgv())->not->toContain('run list');
})->with([
'a GitLab remote' => ['git@gitlab.com:org/repo.git'],
'an unknown Enterprise Server host' => ['git@ghe.example.com:org/repo.git'],
])->skipOnWindows();

test('a fetched baseline that will not decode is discarded rather than trusted', function (): void {
[$project, $environment] = tiaPublishedBaseline('corrupt');

Expand Down
17 changes: 16 additions & 1 deletion tests/Fixtures/Tia/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\FileState;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\GitHubRepository;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
use Pest\Support\Str;
Expand Down Expand Up @@ -274,7 +275,7 @@ public function detachGraph(): string
/**
* @return array<string, string>
*/
public function gh(string $mode = 'ok', string $payload = '{}'): array
public function gh(string $mode = 'ok', string $payload = '{}', string $host = GitHubRepository::DEFAULT_HOST): array
{
self::mirror(__DIR__.'/stubs/gh', $this->path('stub/gh'));
chmod($this->path('stub/gh'), 0755);
Expand All @@ -285,9 +286,23 @@ public function gh(string $mode = 'ok', string $payload = '{}'): array
'PATH' => $this->path('stub').PATH_SEPARATOR.getenv('PATH'),
'GH_STUB_MODE' => $mode,
'GH_STUB_PAYLOAD' => $this->path('payload/graph.json'),
'GH_STUB_HOST' => $host,
'GH_STUB_ARGV_LOG' => $this->path('payload/gh-argv.log'),
];
}

public function ghArgv(): string
{
$path = $this->path('payload/gh-argv.log');

return is_file($path) ? (string) file_get_contents($path) : '';
}

public function origin(string $url): void
{
$this->git()->config('remote.origin.url', $url);
}

public static function testId(string $testFile, string $description): string
{
$basename = basename($testFile, '.php');
Expand Down
11 changes: 11 additions & 0 deletions tests/Fixtures/Tia/stubs/gh
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
#!/bin/sh

[ -n "$GH_STUB_ARGV_LOG" ] && echo "$@" >> "$GH_STUB_ARGV_LOG"

if [ "$1" = "auth" ]; then
[ "$GH_STUB_MODE" = "unauthenticated" ] && exit 1

previous=""
for argument in "$@"; do
if [ "$previous" = "--hostname" ] && [ "$argument" != "$GH_STUB_HOST" ]; then
exit 1
fi
previous="$argument"
done

exit 0
fi

Expand Down
Loading