diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 1f4d63cea..b9289a557 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -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; } @@ -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); @@ -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, )); @@ -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( @@ -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.', @@ -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); @@ -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, ]); @@ -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, @@ -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', @@ -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(); diff --git a/src/Plugins/Tia/GitHubRepository.php b/src/Plugins/Tia/GitHubRepository.php new file mode 100644 index 000000000..4a8b73607 --- /dev/null +++ b/src/Plugins/Tia/GitHubRepository.php @@ -0,0 +1,72 @@ +host === self::DEFAULT_HOST; + } + + public function qualifiedName(): string + { + return $this->isDefaultHost() ? $this->name : $this->host.'/'.$this->name; + } + + /** + * @return array + */ + public function hostnameArguments(): array + { + return $this->isDefaultHost() ? [] : ['--hostname', $this->host]; + } +} diff --git a/tests/Features/Tia/RemoteBaseline.php b/tests/Features/Tia/RemoteBaseline.php index d47fb0a16..9b69f09d3 100644 --- a/tests/Features/Tia/RemoteBaseline.php +++ b/tests/Features/Tia/RemoteBaseline.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Pest\Plugins\Tia\GitHubRepository; use Tests\Fixtures\Tia\Project; afterEach(function (): void { @@ -12,9 +13,18 @@ * @param callable(array): array|null $mutator * @return array{0: Project, 1: array} */ -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(); @@ -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 { @@ -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'); diff --git a/tests/Fixtures/Tia/Project.php b/tests/Fixtures/Tia/Project.php index bead90b35..823812ffd 100644 --- a/tests/Fixtures/Tia/Project.php +++ b/tests/Fixtures/Tia/Project.php @@ -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; @@ -274,7 +275,7 @@ public function detachGraph(): string /** * @return array */ - 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); @@ -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'); diff --git a/tests/Fixtures/Tia/stubs/gh b/tests/Fixtures/Tia/stubs/gh index 7aa960acb..00d9a5e3e 100755 --- a/tests/Fixtures/Tia/stubs/gh +++ b/tests/Fixtures/Tia/stubs/gh @@ -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 diff --git a/tests/Unit/Plugins/Tia/GitHubRepository.php b/tests/Unit/Plugins/Tia/GitHubRepository.php new file mode 100644 index 000000000..a282edcac --- /dev/null +++ b/tests/Unit/Plugins/Tia/GitHubRepository.php @@ -0,0 +1,88 @@ +not->toBeNull() + ->and($repository->host)->toBe('github.com') + ->and($repository->name)->toBe('pestphp/pest') + ->and($repository->isDefaultHost())->toBeTrue(); +})->with([ + 'git@github.com:pestphp/pest.git', + 'git@github.com:pestphp/pest', + 'https://github.com/pestphp/pest.git', + 'https://github.com/pestphp/pest', + 'https://github.com/pestphp/pest/', + 'http://github.com/pestphp/pest', + 'ssh://git@github.com/pestphp/pest.git', + 'ssh://git@github.com:22/pestphp/pest.git', + 'ssh://github.com/pestphp/pest', +]); + +it('parses a github enterprise server remote', function (string $url, string $host, string $name): void { + $repository = GitHubRepository::fromRemoteUrl($url); + + expect($repository)->not->toBeNull() + ->and($repository->host)->toBe($host) + ->and($repository->name)->toBe($name) + ->and($repository->isDefaultHost())->toBeFalse(); +})->with([ + ['git@github.foodics.com:pay/capital-api.git', 'github.foodics.com', 'pay/capital-api'], + ['https://github.example.com/org/repo', 'github.example.com', 'org/repo'], + ['https://github.example.com:8443/org/repo.git', 'github.example.com', 'org/repo'], + ['ssh://git@ghe.example.com:2222/org/repo.git', 'ghe.example.com', 'org/repo'], + ['ssh://GHE.EXAMPLE.COM/org/repo', 'ghe.example.com', 'org/repo'], +]); + +it('rejects a remote that is not a repository url', function (string $url): void { + expect(GitHubRepository::fromRemoteUrl($url))->toBeNull(); +})->with([ + '/an/absolute/path', + '../a/relative/path', + 'file:///an/absolute/path', + 'git://github.com/pestphp/pest.git', + 'https://github.com/pestphp', + 'git@github.com:pestphp', +]); + +it('reads the origin remote of a project', function (): void { + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.uniqid('pest-tia-origin-', true); + + mkdir($root.DIRECTORY_SEPARATOR.'.git', 0755, true); + + file_put_contents($root.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config', <<<'CONFIG' + [core] + repositoryformatversion = 0 + [remote "origin"] + url = git@github.foodics.com:pay/capital-api.git + fetch = +refs/heads/*:refs/remotes/origin/* + CONFIG); + + $repository = GitHubRepository::fromProjectRoot($root); + + expect($repository)->not->toBeNull() + ->and($repository->host)->toBe('github.foodics.com') + ->and($repository->name)->toBe('pay/capital-api'); +}); + +it('has no origin remote to read without a git directory', function (): void { + expect(GitHubRepository::fromProjectRoot(sys_get_temp_dir()))->toBeNull(); +}); + +it('leaves the gh arguments of a github.com repository untouched', function (): void { + $repository = GitHubRepository::fromRemoteUrl('git@github.com:pestphp/pest.git'); + + expect($repository->qualifiedName())->toBe('pestphp/pest') + ->and($repository->hostnameArguments())->toBeEmpty(); +}); + +it('qualifies the gh arguments of a github enterprise server repository', function (): void { + $repository = GitHubRepository::fromRemoteUrl('git@github.foodics.com:pay/capital-api.git'); + + expect($repository->qualifiedName())->toBe('github.foodics.com/pay/capital-api') + ->and($repository->hostnameArguments())->toBe(['--hostname', 'github.foodics.com']); +});