From 811cd92486172ab8002db60ba890a8ee01201e0d Mon Sep 17 00:00:00 2001 From: Yamen Ashraf Date: Tue, 8 Sep 2026 01:53:47 +0400 Subject: [PATCH] fix(tia): support GitHub Enterprise remotes when fetching the shared baseline BaselineSync matched the origin remote against a literal github.com, so fetchIfAvailable() returned false before gh ran on a GitHub Enterprise Server remote. --tia --baselined then turned itself off with no warning, and GH_HOST could not help because gh was never invoked. GitHubRepository now parses the host out of the three remote shapes Pest already accepted. gh run list and gh run download take the documented -R [HOST/]OWNER/REPO, while gh api and gh auth status take --hostname ; the gh api path keeps its repos/OWNER/REPO form. Every call on github.com keeps its current arguments. A host other than github.com counts as GitHub only when gh is authenticated for it, so a GitLab remote stays the local-recording no-op it is today. --- src/Plugins/Tia/BaselineSync.php | 69 ++++++---------- src/Plugins/Tia/GitHubRepository.php | 72 +++++++++++++++++ tests/Features/Tia/RemoteBaseline.php | 52 +++++++++++- tests/Fixtures/Tia/Project.php | 17 +++- tests/Fixtures/Tia/stubs/gh | 11 +++ tests/Unit/Plugins/Tia/GitHubRepository.php | 88 +++++++++++++++++++++ 6 files changed, 258 insertions(+), 51 deletions(-) create mode 100644 src/Plugins/Tia/GitHubRepository.php create mode 100644 tests/Unit/Plugins/Tia/GitHubRepository.php 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']); +});