diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 1d4ca02b..52da47c0 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -266,6 +266,15 @@ public function createCheckRun( throw new \Exception('createCheckRun() is not implemented for ' . $this->getName()); } + /** + * Finds the most recent check run on a commit by name. + * Returns the check run ID, or 0 if none found. + */ + public function getCheckRunByName(string $owner, string $repositoryName, string $ref, string $checkName): int + { + throw new \Exception('getCheckRunByName() is not implemented for ' . $this->getName()); + } + /** * Gets a check run by ID. * diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 1af30388..784cccec 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -955,6 +955,30 @@ public function createCheckRun( return $response['body'] ?? []; } + /** + * Finds the most recent check run on a commit by name. + * Returns the check run ID, or 0 if none found. + */ + public function getCheckRunByName(string $owner, string $repositoryName, string $ref, string $checkName): int + { + $url = "/repos/$owner/$repositoryName/commits/$ref/check-runs"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [ + 'check_name' => $checkName, + 'filter' => 'latest', + 'per_page' => 1, + ]); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + return 0; + } + + $runs = $response['body']['check_runs'] ?? []; + + return (int) ($runs[0]['id'] ?? 0); + } + /** * Gets a check run by ID. * diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f1c4b2fc..2ae28c3b 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -932,6 +932,173 @@ public function testUpdateCheckRunWithMissingConclusion(): void } } + public function testGetCheckRunByName(): void + { + $repositoryName = 'test-get-check-run-by-name-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $foundId = $this->vcsAdapter->getCheckRunByName( + static::$owner, + $repositoryName, + $commitHash, + 'ci/build' + ); + + $this->assertEquals($checkRun['id'], $foundId); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCheckRunByNameNoMatchReturnsZero(): void + { + // Verifies the check_name filter is actually applied: + // a run with a different name must not be returned. + $repositoryName = 'test-get-check-run-by-name-nomatch-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $foundId = $this->vcsAdapter->getCheckRunByName( + static::$owner, + $repositoryName, + $commitHash, + 'ci/lint' // different name + ); + + $this->assertEquals(0, $foundId); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCheckRunByNameInvalidRepositoryReturnsZero(): void + { + // Non-existent repo must return 0, not throw — callers rely on this + // for graceful fallback to the legacy commit status API. + $foundId = $this->vcsAdapter->getCheckRunByName( + static::$owner, + 'non-existing-repository-' . \uniqid(), + str_repeat('a', 40), + 'ci/build' + ); + + $this->assertEquals(0, $foundId); + } + + public function testGetCheckRunByNameReturnsMostRecent(): void + { + // When a commit has multiple runs with the same name (e.g. retries), + // the most recently created one must be returned. + $repositoryName = 'test-get-check-run-by-name-recent-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertGreaterThan($first['id'], $second['id']); + + $foundId = $this->vcsAdapter->getCheckRunByName( + static::$owner, + $repositoryName, + $commitHash, + 'ci/build' + ); + + $this->assertEquals($second['id'], $foundId); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCheckRunByNameThenUpdate(): void + { + // End-to-end: create as in_progress, look up by name, update to completed. + // This is the exact workflow the method was designed for — no stored ID needed. + $repositoryName = 'test-get-check-run-by-name-update-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $checkRunId = $this->vcsAdapter->getCheckRunByName( + static::$owner, + $repositoryName, + $commitHash, + 'ci/build' + ); + + $this->assertGreaterThan(0, $checkRunId); + + $updated = $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRunId, + conclusion: 'success', + title: 'Build succeeded.', + summary: 'All steps passed.', + ); + + $this->assertEquals($checkRunId, $updated['id']); + $this->assertEquals('completed', $updated['status']); + $this->assertEquals('success', $updated['conclusion']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGenerateCloneCommand(): void { $repositoryName = 'test-clone-command-' . \uniqid();