From 000a631636fe827b009865b90998b72b99482fbd Mon Sep 17 00:00:00 2001 From: Caleb White Date: Mon, 10 Aug 2026 14:53:31 -0500 Subject: [PATCH] feat(tia): add GitLab CI baseline support Extract BaselineSync into a provider architecture with an abstract BaseRemote class and concrete GitHubRemote/GitLabRemote implementations. GitLab support uses the glab CLI to fetch TIA baselines from CI pipelines. The GitLabRemote queries the GitLab API for the latest successful job matching the configured name on the default branch, then downloads artifacts via `glab job artifact`. Self-hosted GitLab instances are supported automatically: any non-GitHub remote is detected as GitLab when the glab CLI is installed, with authentication validated at fetch time. Key changes: - BaseRemote abstract class holds shared logic: CLI existence/auth checks, error classification, git remote URL parsing, and default branch resolution - GitHubRemote extracts existing gh CLI logic unchanged - GitLabRemote adds glab CLI support with default job name `tia-baseline`, configurable via `->baselined(job: "name")` - BaselineSync becomes a provider-agnostic orchestrator that iterates registered remotes to detect the matching CI provider - Configuration::baselined() accepts optional `job` parameter for GitLab job name customization - GitLab job query filters by both job name and default branch ref to ensure cache key matches downloaded artifact --- src/Plugins/Tia/BaselineSync.php | 227 +++++--------------- src/Plugins/Tia/Baselines/BaseRemote.php | 148 +++++++++++++ src/Plugins/Tia/Baselines/GitHubRemote.php | 161 ++++++++++++++ src/Plugins/Tia/Baselines/GitLabRemote.php | 220 +++++++++++++++++++ src/Plugins/Tia/Configuration.php | 6 +- src/Plugins/Tia/WatchPatterns.php | 15 ++ tests/Features/Tia/RemoteBaselineGitLab.php | 157 ++++++++++++++ tests/Fixtures/Tia/GitRepo.php | 5 + tests/Fixtures/Tia/Project.php | 19 ++ tests/Fixtures/Tia/stubs/gh | 6 +- tests/Fixtures/Tia/stubs/glab | 55 +++++ 11 files changed, 839 insertions(+), 180 deletions(-) create mode 100644 src/Plugins/Tia/Baselines/BaseRemote.php create mode 100644 src/Plugins/Tia/Baselines/GitHubRemote.php create mode 100644 src/Plugins/Tia/Baselines/GitLabRemote.php create mode 100644 tests/Features/Tia/RemoteBaselineGitLab.php create mode 100755 tests/Fixtures/Tia/stubs/glab diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 1f4d63cea..3c652b69c 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -7,6 +7,7 @@ use Pest\Exceptions\BaselineFetchFailed; use Pest\Panic; use Pest\Plugins\Tia; +use Pest\Plugins\Tia\Baselines\BaseRemote; use Pest\Plugins\Tia\Contracts\State; use Pest\Support\View; use Symfony\Component\Console\Output\OutputInterface; @@ -17,10 +18,6 @@ */ final readonly class BaselineSync { - private const string DEFAULT_WORKFLOW_FILE = 'tia-baseline.yml'; - - private const string ARTIFACT_NAME = 'pest-tia-baseline'; - private const string GRAPH_ASSET = Tia::KEY_GRAPH; private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; @@ -31,27 +28,12 @@ private const int FETCH_COOLDOWN_SECONDS = 86400; - private const array DIAGNOSES = [ - 'network' => [ - 'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i', - 'message' => 'network error (offline or DNS unreachable). Try again when connected.', - ], - 'gh-auth' => [ - 'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i', - 'message' => 'authentication failed — run `gh auth login` and retry.', - ], - 'rate-limit' => [ - 'pattern' => '/rate limit|too many requests|secondary rate limit/i', - 'message' => 'GitHub API rate limit hit — try again later.', - ], - 'not-found' => [ - 'pattern' => '/404|not found|repository not found/i', - 'message' => 'workflow or artifact not found in repo.', - ], - 'forbidden' => [ - 'pattern' => '/403|forbidden|access denied/i', - 'message' => 'access denied — check that your `gh` token has repo + actions read scope.', - ], + /** + * @var array> + */ + private const array REMOTES = [ + Baselines\GitHubRemote::class, + Baselines\GitLabRemote::class, ]; public function __construct( @@ -60,11 +42,6 @@ public function __construct( private WatchPatterns $watchPatterns, ) {} - private function workflowFile(): string - { - return $this->watchPatterns->baselineWorkflow() ?? self::DEFAULT_WORKFLOW_FILE; - } - private function renderBadge(string $type, string $content): void { View::render('components.badge', ['type' => $type, 'content' => $content]); @@ -77,12 +54,14 @@ private function renderChild(string $text): void public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool { - $repo = $this->detectGitHubRepo($projectRoot); + $detected = $this->detectRemote($projectRoot); - if ($repo === null) { + if ($detected === null) { return false; } + [$remote, $repo] = $detected; + if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { $this->renderBadge('WARN', sprintf( 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.', @@ -92,7 +71,7 @@ public function fetchIfAvailable(string $projectRoot, bool $force = false, bool return false; } - $result = $this->download($repo, $projectRoot, $hasAnchor); + $result = $this->download($remote, $repo, $projectRoot, $hasAnchor); $payload = $result['payload']; $failureKind = $result['failureKind']; @@ -118,6 +97,23 @@ public function fetchIfAvailable(string $projectRoot, bool $force = false, bool return true; } + /** + * @return array{0: BaseRemote, 1: string}|null + */ + private function detectRemote(string $projectRoot): ?array + { + foreach (self::REMOTES as $class) { + $remote = new $class($this->watchPatterns); + $repo = $remote->detect($projectRoot); + + if ($repo !== null) { + return [$remote, $repo]; + } + } + + return null; + } + private function cooldownRemaining(): ?int { $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); @@ -181,52 +177,17 @@ private function isCi(): bool || getenv('CIRCLECI') === 'true'; } - private function detectGitHubRepo(string $projectRoot): ?string - { - $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; - } - - $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]; - } - - return 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(BaseRemote $remote, string $repo, string $projectRoot, bool $hasAnchor = false): array { - $this->validateGhDependencies($hasAnchor); + $this->validateCliDependencies($remote, $hasAnchor); - [$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo); + [$runId, $listError] = $remote->latestSuccessfulRunId($repo); if ($listError !== null) { - $this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor); + $this->panicOnClassifiedError($remote, $listError, 'Failed to query baseline runs', $hasAnchor); $this->renderBadge('WARN', sprintf( 'Failed to query baseline runs — %s', @@ -258,7 +219,7 @@ private function download(string $repo, string $projectRoot, bool $hasAnchor = f return ['payload' => null, 'failureKind' => null]; } - $download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor); + $download = $this->downloadArtifact($remote, $repo, $runId, $runCacheDir, $hasAnchor); if (! $download['success']) { return ['payload' => null, 'failureKind' => $download['failureKind']]; @@ -274,7 +235,7 @@ private function download(string $repo, string $projectRoot, bool $hasAnchor = f /** * @param array{kind: string, message: string} $diagnosis */ - private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void + private function panicOnClassifiedError(BaseRemote $remote, array $diagnosis, string $contextPrefix, bool $hasAnchor): void { if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) { return; @@ -282,25 +243,25 @@ private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, Panic::with(new BaselineFetchFailed( sprintf('%s — %s', $contextPrefix, $diagnosis['message']), - sprintf('Verify workflow [%s], artifact [%s], and gh token scope.', $this->workflowFile(), self::ARTIFACT_NAME), + sprintf('Verify your CI baseline configuration and `%s` token scope.', $remote->cliName()), $hasAnchor, )); } - private function validateGhDependencies(bool $hasAnchor): void + private function validateCliDependencies(BaseRemote $remote, bool $hasAnchor): void { - if (! $this->commandExists('gh')) { + if (! $remote->cliExists()) { Panic::with(new BaselineFetchFailed( - 'GitHub CLI (gh) not found — cannot fetch baseline.', - 'Install it from https://cli.github.com.', + sprintf('%s CLI (%s) not found — cannot fetch baseline.', $remote->providerLabel(), $remote->cliName()), + sprintf('Install it from %s.', $remote->installUrl()), $hasAnchor, )); } - if (! $this->ghAuthenticated()) { + if (! $remote->cliAuthenticated()) { Panic::with(new BaselineFetchFailed( - 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', - 'Run `gh auth login` and retry.', + sprintf('%s CLI (%s) is not authenticated — cannot fetch baseline.', $remote->providerLabel(), $remote->cliName()), + sprintf('Run `%s` and retry.', $remote->loginCommand()), $hasAnchor, )); } @@ -309,9 +270,9 @@ 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(BaseRemote $remote, string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array { - $artifactSize = $this->artifactSize($repo, $runId); + $artifactSize = $remote->artifactSize($repo, $runId); $this->output->writeln(''); $this->renderChild($artifactSize !== null @@ -325,12 +286,7 @@ private function downloadArtifact(string $repo, string $runId, string $runCacheD $repo, )); - $process = new Process([ - 'gh', 'run', 'download', $runId, - '-R', $repo, - '-n', self::ARTIFACT_NAME, - '-D', $runCacheDir, - ]); + $process = new Process($remote->downloadCommand($repo, $runId, $runCacheDir)); $process->setTimeout(900.0); $process->start(); @@ -351,9 +307,9 @@ private function downloadArtifact(string $repo, string $runId, string $runCacheD $this->cleanup($runCacheDir); - $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); + $diagnosis = $remote->classifyError($process->getErrorOutput().$process->getOutput()); - $this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor); + $this->panicOnClassifiedError($remote, $diagnosis, 'Baseline download failed', $hasAnchor); $this->renderBadge('WARN', sprintf( 'Baseline download failed — %s', @@ -375,7 +331,7 @@ private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor Panic::with(new BaselineFetchFailed( 'Baseline downloaded but the artifact is missing expected files (graph.json).', - 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.', + 'Your CI publish step is broken — check the job that uploads the TIA baseline artifact.', $hasAnchor, )); } @@ -383,28 +339,6 @@ private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor return $payload; } - private function artifactSize(string $repo, string $runId): ?int - { - $process = new Process([ - 'gh', 'api', - sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId), - '--jq', sprintf( - '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type - self::ARTIFACT_NAME, - ), - ]); - $process->setTimeout(30.0); - $process->run(); - - if (! $process->isSuccessful()) { - return null; - } - - $size = trim($process->getOutput()); - - return is_numeric($size) ? (int) $size : null; - } - private function renderDownloadProgress(float $startedAt, int $tick): void { static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -526,69 +460,6 @@ private function trimDownloadCache(string $projectRoot): void } } - /** - * @return array{0: ?string, 1: ?array{kind: string, message: string}} - */ - private function latestSuccessfulRunIdWithError(string $repo): array - { - $process = new Process([ - 'gh', 'run', 'list', - '-R', $repo, - '--workflow', $this->workflowFile(), - '--status', 'success', - '--limit', '1', - '--json', 'databaseId', - '--jq', '.[0].databaseId // empty', - ]); - $process->setTimeout(30.0); - $process->run(); - - if (! $process->isSuccessful()) { - return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())]; - } - - $runId = trim($process->getOutput()); - - return [$runId === '' ? null : $runId, null]; - } - - private function ghAuthenticated(): bool - { - $process = new Process(['gh', 'auth', 'status']); - $process->setTimeout(10.0); - $process->run(); - - return $process->isSuccessful(); - } - - /** - * @return array{kind: string, message: string} - */ - private function classifyGhError(string $output): array - { - $output = trim($output); - - if ($output === '') { - return ['kind' => 'unknown', 'message' => 'unknown error']; - } - - foreach (self::DIAGNOSES as $kind => $diagnosis) { - if (preg_match($diagnosis['pattern'], $output) === 1) { - return ['kind' => $kind, 'message' => $diagnosis['message']]; - } - } - - return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))]; - } - - private function commandExists(string $cmd): bool - { - $process = new Process(['which', $cmd]); - $process->run(); - - return $process->isSuccessful(); - } - private function cleanup(string $dir): void { if (! is_dir($dir)) { diff --git a/src/Plugins/Tia/Baselines/BaseRemote.php b/src/Plugins/Tia/Baselines/BaseRemote.php new file mode 100644 index 000000000..038661c11 --- /dev/null +++ b/src/Plugins/Tia/Baselines/BaseRemote.php @@ -0,0 +1,148 @@ + + */ + abstract public function downloadCommand(string $repo, string $runId, string $destDir): array; + + public function cliExists(): bool + { + $process = new Process(['which', $this->cliName()]); + $process->run(); + + return $process->isSuccessful(); + } + + public function cliAuthenticated(): bool + { + $process = new Process([$this->cliName(), 'auth', 'status']); + $process->setTimeout(10.0); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * @return array{kind: string, message: string} + */ + public function classifyError(string $output): array + { + $output = trim($output); + + if ($output === '') { + return ['kind' => 'unknown', 'message' => 'unknown error']; + } + + foreach ($this->diagnoses() as $kind => $diagnosis) { + if (preg_match($diagnosis['pattern'], $output) === 1) { + return ['kind' => $kind, 'message' => $diagnosis['message']]; + } + } + + return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))]; + } + + protected function readOriginUrl(string $projectRoot): ?string + { + $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 $match[1]; + } + + /** + * @return array{0: string, 1: string}|null + */ + protected function parseRemoteUrl(string $url): ?array + { + if (preg_match('#^git@([^:]+):([\w./-]+?)(?:\.git)?$#', $url, $m) === 1) { + return [$m[1], $m[2]]; + } + + if (preg_match('#^https?://([^/]+)/([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) { + return [$m[1], $m[2]]; + } + + if (preg_match('#^ssh://(?:[^@/]+@)?([^:/]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { + return [$m[1], $m[2]]; + } + + return null; + } + + protected function resolveDefaultBranch(string $repo): string + { + $configured = $this->watchPatterns->defaultBranch(); + + if ($configured !== null) { + return $configured; + } + + $process = new Process(['git', 'symbolic-ref', 'refs/remotes/origin/HEAD']); + $process->setTimeout(5.0); + $process->run(); + + if ($process->isSuccessful()) { + $ref = trim($process->getOutput()); + + $branch = str_replace('refs/remotes/origin/', '', $ref); + + if ($branch !== '') { + return $branch; + } + } + + return $this->remoteDefaultBranch($repo) ?? 'main'; + } + + abstract protected function remoteDefaultBranch(string $repo): ?string; + + /** + * @return array + */ + abstract protected function diagnoses(): array; +} diff --git a/src/Plugins/Tia/Baselines/GitHubRemote.php b/src/Plugins/Tia/Baselines/GitHubRemote.php new file mode 100644 index 000000000..dac51786d --- /dev/null +++ b/src/Plugins/Tia/Baselines/GitHubRemote.php @@ -0,0 +1,161 @@ +readOriginUrl($projectRoot); + + if ($url === null) { + return null; + } + + $parsed = $this->parseRemoteUrl($url); + + if ($parsed === null) { + return null; + } + + [$hostname, $path] = $parsed; + + return strcasecmp($hostname, 'github.com') === 0 ? $path : null; + } + + public function cliName(): string + { + return 'gh'; + } + + public function installUrl(): string + { + return 'https://cli.github.com'; + } + + public function loginCommand(): string + { + return 'gh auth login'; + } + + public function providerLabel(): string + { + return 'GitHub'; + } + + public function latestSuccessfulRunId(string $repo): array + { + $process = new Process([ + 'gh', 'run', 'list', + '-R', $repo, + '--workflow', $this->workflowFile(), + '--status', 'success', + '--limit', '1', + '--json', 'databaseId', + '--jq', '.[0].databaseId // empty', + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return [null, $this->classifyError($process->getErrorOutput().$process->getOutput())]; + } + + $runId = trim($process->getOutput()); + + return [$runId === '' ? null : $runId, null]; + } + + public function artifactSize(string $repo, string $runId): ?int + { + $process = new Process([ + 'gh', 'api', + sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId), + '--jq', sprintf( + '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type + self::ARTIFACT_NAME, + ), + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $size = trim($process->getOutput()); + + return is_numeric($size) ? (int) $size : null; + } + + public function downloadCommand(string $repo, string $runId, string $destDir): array + { + return [ + 'gh', 'run', 'download', $runId, + '-R', $repo, + '-n', self::ARTIFACT_NAME, + '-D', $destDir, + ]; + } + + protected function remoteDefaultBranch(string $repo): ?string + { + $process = new Process([ + 'gh', 'api', + sprintf('repos/%s', $repo), + '--jq', '.default_branch', + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $branch = trim($process->getOutput()); + + return $branch !== '' ? $branch : null; + } + + protected function diagnoses(): array + { + return [ + 'network' => [ + 'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i', + 'message' => 'network error (offline or DNS unreachable). Try again when connected.', + ], + 'gh-auth' => [ + 'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i', + 'message' => 'authentication failed — run `gh auth login` and retry.', + ], + 'rate-limit' => [ + 'pattern' => '/rate limit|too many requests|secondary rate limit/i', + 'message' => 'GitHub API rate limit hit — try again later.', + ], + 'not-found' => [ + 'pattern' => '/404|not found|repository not found/i', + 'message' => 'workflow or artifact not found in repo.', + ], + 'forbidden' => [ + 'pattern' => '/403|forbidden|access denied/i', + 'message' => 'access denied — check that your `gh` token has repo + actions read scope.', + ], + ]; + } + + private function workflowFile(): string + { + return $this->watchPatterns->baselineWorkflow() ?? self::DEFAULT_WORKFLOW_FILE; + } +} diff --git a/src/Plugins/Tia/Baselines/GitLabRemote.php b/src/Plugins/Tia/Baselines/GitLabRemote.php new file mode 100644 index 000000000..dbde0a683 --- /dev/null +++ b/src/Plugins/Tia/Baselines/GitLabRemote.php @@ -0,0 +1,220 @@ +readOriginUrl($projectRoot); + + if ($url === null) { + return null; + } + + $parsed = $this->parseRemoteUrl($url); + + if ($parsed === null) { + return null; + } + + [$hostname, $path] = $parsed; + + if (strcasecmp($hostname, 'github.com') === 0) { + return null; + } + + if (strcasecmp($hostname, 'gitlab.com') === 0) { + return $path; + } + + if (! $this->cliExists()) { + return null; + } + + return $path; + } + + public function cliName(): string + { + return 'glab'; + } + + public function installUrl(): string + { + return 'https://gitlab.com/gitlab-org/cli#installation'; + } + + public function loginCommand(): string + { + return 'glab auth login'; + } + + public function providerLabel(): string + { + return 'GitLab'; + } + + public function latestSuccessfulRunId(string $repo): array + { + $encodedRepo = rawurlencode($repo); + $jobName = $this->jobName(); + $defaultBranch = $this->resolveDefaultBranch($repo); + + $process = new Process([ + 'glab', 'api', + sprintf('projects/%s/jobs?scope[]=success&per_page=100', $encodedRepo), + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return [null, $this->classifyError($process->getErrorOutput().$process->getOutput())]; + } + + $runId = $this->findPipelineId($process->getOutput(), $jobName, $defaultBranch); + + return [$runId, null]; + } + + public function artifactSize(string $repo, string $runId): ?int + { + $encodedRepo = rawurlencode($repo); + $jobName = $this->jobName(); + + $process = new Process([ + 'glab', 'api', + sprintf('projects/%s/pipelines/%s/jobs', $encodedRepo, $runId), + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + return $this->findArtifactSize($process->getOutput(), $jobName); + } + + public function downloadCommand(string $repo, string $runId, string $destDir): array + { + return [ + 'glab', 'job', 'artifact', + $this->resolveDefaultBranch($repo), + $this->jobName(), + '-R', $repo, + '--path', $destDir, + ]; + } + + protected function remoteDefaultBranch(string $repo): ?string + { + $process = new Process([ + 'glab', 'api', + sprintf('projects/%s', rawurlencode($repo)), + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $project = json_decode($process->getOutput(), true); + $branch = is_array($project) ? ($project['default_branch'] ?? null) : null; + + return is_string($branch) && $branch !== '' ? $branch : null; + } + + protected function diagnoses(): array + { + return [ + 'network' => [ + 'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure|network is unreachable|no route to host|tls handshake|dial tcp/i', + 'message' => 'network error (offline or DNS unreachable). Try again when connected.', + ], + 'glab-auth' => [ + 'pattern' => '/authentication failed|not logged in|requires authentication|unauthorized|401|token expired|invalid token/i', + 'message' => 'authentication failed — run `glab auth login` and retry.', + ], + 'rate-limit' => [ + 'pattern' => '/rate limit|too many requests|429/i', + 'message' => 'GitLab API rate limit hit — try again later.', + ], + 'not-found' => [ + 'pattern' => '/404|not found|project not found|does not exist/i', + 'message' => 'pipeline, job, or artifact not found in project.', + ], + 'forbidden' => [ + 'pattern' => '/403|forbidden|access denied|insufficient scope/i', + 'message' => 'access denied — check that your `glab` token has read_api scope.', + ], + ]; + } + + private function jobName(): string + { + return $this->watchPatterns->baselineJob() ?? self::DEFAULT_JOB_NAME; + } + + private function findPipelineId(string $output, string $jobName, string $defaultBranch): ?string + { + $jobs = json_decode($output, true); + + if (! is_array($jobs)) { + return null; + } + + foreach ($jobs as $job) { + if (! is_array($job)) { + continue; + } + + if (($job['name'] ?? null) !== $jobName || ($job['ref'] ?? null) !== $defaultBranch) { + continue; + } + + $id = $job['pipeline']['id'] ?? null; + + return is_int($id) || is_string($id) ? (string) $id : null; + } + + return null; + } + + private function findArtifactSize(string $output, string $jobName): ?int + { + $jobs = json_decode($output, true); + + if (! is_array($jobs)) { + return null; + } + + foreach ($jobs as $job) { + if (! is_array($job) || ($job['name'] ?? null) !== $jobName || ! is_array($job['artifacts'] ?? null)) { + continue; + } + + $size = 0; + + foreach ($job['artifacts'] as $artifact) { + if (is_array($artifact) && is_int($artifact['size'] ?? null)) { + $size += $artifact['size']; + } + } + + return $size; + } + + return null; + } +} diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index af35f9a3e..f84ba9833 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -61,7 +61,7 @@ public function filtered(): self /** * @return $this */ - public function baselined(?string $workflow = null): self + public function baselined(?string $workflow = null, ?string $job = null): self { /** @var WatchPatterns $watchPatterns */ $watchPatterns = Container::getInstance()->get(WatchPatterns::class); @@ -71,6 +71,10 @@ public function baselined(?string $workflow = null): self $watchPatterns->setBaselineWorkflow($workflow); } + if ($job !== null) { + $watchPatterns->setBaselineJob($job); + } + return $this; } diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php index 35ed409eb..92d945344 100644 --- a/src/Plugins/Tia/WatchPatterns.php +++ b/src/Plugins/Tia/WatchPatterns.php @@ -48,6 +48,8 @@ final class WatchPatterns private ?string $baselineWorkflow = null; + private ?string $baselineJob = null; + public function useDefaults(string $projectRoot): void { $testPath = TestSuite::getInstance()->testPath; @@ -203,6 +205,18 @@ public function baselineWorkflow(): ?string return $this->baselineWorkflow; } + public function setBaselineJob(string $job): void + { + $job = trim($job); + + $this->baselineJob = $job === '' ? null : $job; + } + + public function baselineJob(): ?string + { + return $this->baselineJob; + } + public function reset(): void { $this->patterns = []; @@ -213,6 +227,7 @@ public function reset(): void $this->baselined = false; $this->defaultBranch = null; $this->baselineWorkflow = null; + $this->baselineJob = null; } private function keyMatches(string $key, string $file): bool diff --git a/tests/Features/Tia/RemoteBaselineGitLab.php b/tests/Features/Tia/RemoteBaselineGitLab.php new file mode 100644 index 000000000..70772b915 --- /dev/null +++ b/tests/Features/Tia/RemoteBaselineGitLab.php @@ -0,0 +1,157 @@ +): array|null $mutator + * @return array{0: Project, 1: array} + */ +function tiaPublishedBaselineGitLab(string $mode = 'ok', ?callable $mutator = null): array +{ + $project = Project::make('master'); + $project->seed('master'); + + $payload = $project->detachGraph(); + + if ($mutator !== null) { + /** @var array $decoded */ + $decoded = json_decode($payload, true); + $payload = (string) json_encode($mutator($decoded), JSON_UNESCAPED_SLASHES); + } + + return [$project, $project->glab($mode, $payload)]; +} + +test('a self-hosted GitLab baseline is fetched when glab is available', function (): void { + $project = Project::make('master'); + $project->seed('master'); + + $payload = $project->detachGraph(); + + $environment = $project->glab('ok', $payload, 'gitlab.acme.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->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($project->graphExists())->toBeTrue(); +})->skipOnWindows(); + +test('a published GitLab baseline is fetched instead of recorded locally', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab(); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + 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(); +})->skipOnWindows(); + +test('a fetched GitLab baseline that will not decode is discarded rather than trusted', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('corrupt'); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->output)->toContain('The dependency graph could not be read') + ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed') + ->and($result->replayed())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('a fetched GitLab baseline recorded against another tree is not used', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('ok', function (array $graph): array { + $graph['fingerprint']['structural']['composer_lock'] = 'a-lockfile-this-project-never-had'; + + return $graph; + }); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed') + ->and($result->replayed())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('a GitLab artifact without a graph in it fails loudly', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('missing-asset'); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(1, $result->describe()) + ->and($result->output)->toContain('the artifact is missing expected files') + ->and($project->graphExists())->toBeFalse(); +})->skipOnWindows(); + +test('a GitLab baseline that cannot be authenticated for fails loudly', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('unauthenticated'); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(1, $result->describe()) + ->and($result->output)->toContain('is not authenticated') + ->and($project->graphExists())->toBeFalse(); +})->skipOnWindows(); + +test('a GitLab pipeline or job that is not there fails loudly', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('list-404'); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(1, $result->describe()) + ->and($result->output)->toContain('not found in project') + ->and($project->graphExists())->toBeFalse(); +})->skipOnWindows(); + +test('a GitLab network failure warns and lets the suite run', function (string $mode): void { + [$project, $environment] = tiaPublishedBaselineGitLab($mode); + + $result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->output)->toContain('network error') + ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed'); +})->with([ + 'querying the runs' => ['list-network'], + 'downloading the artifact' => ['download-network'], +])->skipOnWindows(); + +test('no published GitLab baseline yet starts a cooldown', function (): void { + [$project, $environment] = tiaPublishedBaselineGitLab('no-runs'); + + $discardGraph = function () use ($project): void { + if ($project->graphExists()) { + $project->detachGraph(); + } + }; + + $first = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($first->exitCode)->toBe(0, $first->describe()) + ->and($first->output)->toContain('No baseline published yet') + ->and($project->graphDir().DIRECTORY_SEPARATOR.'fetch-cooldown.json')->toBeFile(); + + $discardGraph(); + + $second = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($second->exitCode)->toBe(0, $second->describe()) + ->and($second->output)->toContain('next auto-retry in'); + + file_put_contents($project->graphDir().DIRECTORY_SEPARATOR.'fetch-cooldown.json', 'not json{'); + + $discardGraph(); + + $third = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined'); + + expect($third->exitCode)->toBe(0, $third->describe()) + ->and($third->output)->toContain('No baseline published yet') + ->and($third->tally())->toContain(Project::TOTAL_TESTS.' passed'); +})->skipOnWindows(); diff --git a/tests/Fixtures/Tia/GitRepo.php b/tests/Fixtures/Tia/GitRepo.php index 3b114dd9f..1025815f2 100644 --- a/tests/Fixtures/Tia/GitRepo.php +++ b/tests/Fixtures/Tia/GitRepo.php @@ -65,6 +65,11 @@ public function removeOrigin(): void $this->run(['remote', 'remove', 'origin']); } + public function setOriginUrl(string $url): void + { + $this->run(['remote', 'set-url', 'origin', $url]); + } + public function setOriginHead(string $branch): void { $this->run(['update-ref', 'refs/remotes/origin/'.$branch, 'HEAD']); diff --git a/tests/Fixtures/Tia/Project.php b/tests/Fixtures/Tia/Project.php index bead90b35..49cce643a 100644 --- a/tests/Fixtures/Tia/Project.php +++ b/tests/Fixtures/Tia/Project.php @@ -288,6 +288,25 @@ public function gh(string $mode = 'ok', string $payload = '{}'): array ]; } + /** + * @return array + */ + public function glab(string $mode = 'ok', string $payload = '{}', string $host = 'gitlab.com'): array + { + $this->git()->setOriginUrl(sprintf('git@%s:pestphp/tia-fixture.git', $host)); + + self::mirror(__DIR__.'/stubs/glab', $this->path('stub/glab')); + chmod($this->path('stub/glab'), 0755); + + $this->write('payload/graph.json', $payload); + + return [ + 'PATH' => $this->path('stub').PATH_SEPARATOR.getenv('PATH'), + 'GLAB_STUB_MODE' => $mode, + 'GLAB_STUB_PAYLOAD' => $this->path('payload/graph.json'), + ]; + } + 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..82a9e2826 100755 --- a/tests/Fixtures/Tia/stubs/gh +++ b/tests/Fixtures/Tia/stubs/gh @@ -16,7 +16,11 @@ if [ "$1" = "run" ] && [ "$2" = "list" ]; then fi if [ "$1" = "api" ]; then - echo 2048 + case "$2" in + repos/*/actions/runs/*/artifacts) echo 2048 ;; + repos/*) printf '{"default_branch":"master"}' ;; + *) echo 2048 ;; + esac exit 0 fi diff --git a/tests/Fixtures/Tia/stubs/glab b/tests/Fixtures/Tia/stubs/glab new file mode 100755 index 000000000..e58ca6207 --- /dev/null +++ b/tests/Fixtures/Tia/stubs/glab @@ -0,0 +1,55 @@ +#!/bin/sh + +if [ "$1" = "auth" ]; then + [ "$GLAB_STUB_MODE" = "unauthenticated" ] && exit 1 + exit 0 +fi + +if [ "$1" = "api" ]; then + case "$2" in + *jobs\?scope*) + case "$GLAB_STUB_MODE" in + no-runs) exit 0 ;; + list-404) echo "404 Not Found" >&2; exit 1 ;; + list-network) echo "could not resolve host" >&2; exit 1 ;; + esac + printf '[{"name":"tia-baseline","ref":"master","pipeline":{"id":987654321}}]' + exit 0 + ;; + *pipelines/*/jobs*) + printf '[{"name":"tia-baseline","artifacts":[{"size":2048}]}]' + exit 0 + ;; + projects/*) + printf '{"default_branch":"master"}' + exit 0 + ;; + esac + exit 0 +fi + +if [ "$1" = "job" ] && [ "$2" = "artifact" ]; then + case "$GLAB_STUB_MODE" in + download-403) echo "403 Forbidden" >&2; exit 1 ;; + download-network) echo "connection refused" >&2; exit 1 ;; + esac + + dir="" + previous="" + for argument in "$@"; do + [ "$previous" = "--path" ] && dir="$argument" + previous="$argument" + done + + [ -z "$dir" ] && exit 1 + + case "$GLAB_STUB_MODE" in + missing-asset) echo "{}" > "$dir/other.json" ;; + corrupt) printf 'not json at all' > "$dir/graph.json" ;; + *) cp "$GLAB_STUB_PAYLOAD" "$dir/graph.json" ;; + esac + + exit 0 +fi + +exit 1