diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 1d4ca02b..e986e09f 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -266,6 +266,14 @@ public function createCheckRun( throw new \Exception('createCheckRun() is not implemented for ' . $this->getName()); } + /** + * Returns the ID of the most recently created check run with the given name on a commit ref, 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..59af56f2 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -955,6 +955,33 @@ public function createCheckRun( return $response['body'] ?? []; } + 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' => 'all', + 'per_page' => 100, + ]); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + return 0; + } + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get check run by name: HTTP $responseHeadersStatusCode"); + } + + $runs = $response['body']['check_runs'] ?? []; + if (empty($runs)) { + return 0; + } + + // GitHub IDs are monotonically increasing; the highest ID is the most recently created run. + return (int) max(array_column($runs, 'id')); + } + /** * Gets a check run by ID. * diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f1c4b2fc..bd728e95 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -932,6 +932,171 @@ 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 testGetCheckRunByNameNotFoundRepositoryReturnsZero(): void + { + $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(); @@ -1112,4 +1277,5 @@ public function testUpdateComment(): void { $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); } + }