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