diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index be9504de..1d4ca02b 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -235,6 +235,78 @@ abstract public function listBranches(string $owner, string $repositoryName): ar */ abstract public function updateCommitStatus(string $repositoryName, string $SHA, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void; + /** + * Creates a check run for a commit. + * status can be one of: queued, in_progress + * Use updateCheckRun() to set conclusion and mark the run as completed. + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function createCheckRun( + string $owner, + string $repositoryName, + string $headSha, + string $name, + string $status = 'queued', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + throw new \Exception('createCheckRun() is not implemented for ' . $this->getName()); + } + + /** + * Gets a check run by ID. + * + * @return array + */ + public function getCheckRun(string $owner, string $repositoryName, int $checkRunId): array + { + throw new \Exception('getCheckRun() is not implemented for ' . $this->getName()); + } + + /** + * Updates an existing check run. + * status can be one of: queued, in_progress, completed + * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function updateCheckRun( + string $owner, + string $repositoryName, + int $checkRunId, + string $name = '', + string $status = '', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + throw new \Exception('updateCheckRun() is not implemented for ' . $this->getName()); + } + /** * Get repository tree * diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index a06337ee..1af30388 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -872,6 +872,185 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); } + /** + * Creates a check run for a commit. + * status can be one of: queued, in_progress, completed + * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function createCheckRun( + string $owner, + string $repositoryName, + string $headSha, + string $name, + string $status = 'queued', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + $url = "/repos/$owner/$repositoryName/check-runs"; + + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $body = array_merge( + [ + 'name' => $name, + 'head_sha' => $headSha, + 'status' => $status, + ], + array_filter([ + 'conclusion' => $conclusion, + 'completed_at' => $completedAt, + 'details_url' => $detailsUrl, + 'external_id' => $externalId, + 'started_at' => $startedAt, + ], fn ($value) => !empty($value)) + ); + + // Output requires both title and summary. + if (!empty($title) && !empty($summary)) { + $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + if (!empty($annotations)) { + $output['annotations'] = $annotations; + } + if (!empty($images)) { + $output['images'] = $images; + } + $body['output'] = $output; + } + + if (!empty($actions)) { + $body['actions'] = $actions; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create check run: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + + /** + * Gets a check run by ID. + * + * @return array + */ + public function getCheckRun(string $owner, string $repositoryName, int $checkRunId): array + { + $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get check run $checkRunId: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + + /** + * Updates an existing check run. + * status can be one of: queued, in_progress, completed + * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out + * + * @param array $annotations + * @param array $images + * @return array + */ + public function updateCheckRun( + string $owner, + string $repositoryName, + int $checkRunId, + string $name = '', + string $status = '', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; + + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $body = array_filter([ + 'name' => $name, + 'status' => $status, + 'details_url' => $detailsUrl, + 'external_id' => $externalId, + 'started_at' => $startedAt, + 'conclusion' => $conclusion, + 'completed_at' => $completedAt, + ], fn ($value) => !empty($value)); + + // Output requires both title and summary. + if (!empty($title) && !empty($summary)) { + $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + if (!empty($annotations)) { + $output['annotations'] = $annotations; + } + if (!empty($images)) { + $output['images'] = $images; + } + $body['output'] = $output; + } + + if (!empty($actions)) { + $body['actions'] = $actions; + } + + $response = $this->call(self::METHOD_PATCH, $url, ['Authorization' => "Bearer $this->accessToken"], $body); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to update check run $checkRunId: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + /** * Generates a clone command using app access token */ diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f929b3a5..f1c4b2fc 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -649,6 +649,289 @@ public function testUpdateCommitStatus(): void } } + public function testCreateCheckRun(): void + { + $repositoryName = 'test-create-check-run-' . \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', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('in_progress', $checkRun['status']); + $this->assertNull($checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['started_at']); + $this->assertNull($checkRun['completed_at']); + + $fetched = $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, $checkRun['id']); + $this->assertEquals($checkRun['id'], $fetched['id']); + $this->assertEquals('ci/build', $fetched['name']); + $this->assertEquals('in_progress', $fetched['status']); + $this->assertNull($fetched['conclusion']); + $this->assertEquals($commitHash, $fetched['head_sha']); + $this->assertNotEmpty($fetched['url']); + $this->assertNotEmpty($fetched['html_url']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunWithInvalidRepository(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + headSha: 'a' . str_repeat('0', 39), + name: 'ci/build', + ); + } + + public function testGetCheckRunWithInvalidId(): void + { + $repositoryName = 'test-get-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, 999999999); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateTwoCheckRunsOnSameCommit(): void + { + $repositoryName = 'test-two-check-runs-same-commit-' . \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->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash, $first['head_sha']); + $this->assertEquals($commitHash, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void + { + $repositoryName = 'test-check-runs-different-commits-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit1 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash1 = $commit1['commitHash']; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.md', '# Second'); + $commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash2 = $commit2['commitHash']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash1, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash2, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash1, $first['head_sha']); + $this->assertEquals($commitHash2, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunCompleted(): void + { + $repositoryName = 'test-create-check-run-completed-' . \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', + conclusion: 'success', + title: 'Build passed', + summary: 'All checks passed successfully.', + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('completed', $checkRun['status']); + $this->assertEquals('success', $checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['completed_at']); + $this->assertEquals('Build passed', $checkRun['output']['title']); + $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRun(): void + { + $repositoryName = 'test-update-check-run-' . \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', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertEquals('in_progress', $checkRun['status']); + + $updated = $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + conclusion: 'neutral', + title: 'Deployment skipped', + summary: 'Deployment skipped because the branch does not match the configured branch triggers.', + completedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertEquals($checkRun['id'], $updated['id']); + $this->assertEquals('completed', $updated['status']); + $this->assertEquals('neutral', $updated['conclusion']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRunWithInvalidRepository(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + checkRunId: 999999999, + conclusion: 'success', + ); + } + + public function testUpdateCheckRunWithInvalidId(): void + { + $repositoryName = 'test-update-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: 999999999, + conclusion: 'success', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRunWithMissingConclusion(): void + { + $repositoryName = 'test-update-check-run-no-conclusion-' . \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', + ); + + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGenerateCloneCommand(): void { $repositoryName = 'test-clone-command-' . \uniqid();