diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index b72ed680..6a4a7edf 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -45,7 +45,7 @@ public function getType(): string * @param string $message Commit message * @return array Response from API */ - abstract public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file'): array; + abstract public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array; /** * Create a branch in a repository @@ -57,4 +57,17 @@ abstract public function createFile(string $owner, string $repositoryName, strin * @return array Response from API */ abstract public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array; + + /** + * Create a pull request + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $title PR title + * @param string $head Source branch + * @param string $base Target branch + * @param string $body PR description (optional) + * @return array Created PR details + */ + abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array; } diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index a7394ea4..b57290d1 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -93,20 +93,61 @@ public function createRepository(string $owner, string $repositoryName, bool $pr return $response['body'] ?? []; } + /** + * Create a pull request + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $title PR title + * @param string $head Source branch + * @param string $base Target branch + * @param string $body PR description (optional) + * @return array Created PR details + */ + public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array + { + throw new Exception('Not implemented'); + } /** * Create a file in a repository * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param string $filepath Path where file should be created - * @param string $content Content of the file - * @param string $message Commit message + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $filepath Path where file should be created + * @param string $content Content of the file + * @param string $message Commit message + * @param string $branch Branch to create file on (optional) * @return array Response from API */ - public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file'): array + public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { - throw new Exception("Not implemented"); + $url = "/repos/{$owner}/{$repositoryName}/contents/{$filepath}"; + + $payload = [ + 'message' => $message, + 'content' => base64_encode($content), + ]; + + // GitHub supports branch parameter + if (! empty($branch)) { + $payload['branch'] = $branch; + } + + $response = $this->call( + self::METHOD_PUT, + $url, + ['Authorization' => "Bearer $this->accessToken"], + $payload + ); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create file {$filepath}: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; } /** diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 568869a4..8f13071e 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -188,18 +188,25 @@ public function getRepositoryTree(string $owner, string $repositoryName, string * @param string $message Commit message * @return array Response from API */ - public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file'): array + public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { $url = "/repos/{$owner}/{$repositoryName}/contents/{$filepath}"; + $payload = [ + 'content' => base64_encode($content), + 'message' => $message + ]; + + // Add branch if specified + if (!empty($branch)) { + $payload['branch'] = $branch; + } + $response = $this->call( self::METHOD_POST, $url, ['Authorization' => "token $this->accessToken"], - [ - 'content' => base64_encode($content), - 'message' => $message - ] + $payload ); $responseHeaders = $response['headers'] ?? []; @@ -347,19 +354,100 @@ public function deleteRepository(string $owner, string $repositoryName): bool return true; } + /** + * Create a pull request + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $title PR title + * @param string $head Source branch + * @param string $base Target branch + * @param string $body PR description (optional) + * @return array Created PR details + */ + public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array + { + $url = "/repos/{$owner}/{$repositoryName}/pulls"; + + $payload = [ + 'title' => $title, + 'head' => $head, + 'base' => $base, + ]; + + if (!empty($body)) { + $payload['body'] = $body; + } + + $response = $this->call( + self::METHOD_POST, + $url, + ['Authorization' => "token $this->accessToken"], + $payload + ); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create pull request: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + return $responseBody; + } + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { - throw new Exception("Not implemented yet"); + $url = "/repos/{$owner}/{$repositoryName}/issues/{$pullRequestNumber}/comments"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "token $this->accessToken"], ['body' => $comment]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create comment: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + if (!array_key_exists('id', $responseBody)) { + throw new Exception("Comment creation response is missing comment ID."); + } + + return (string) ($responseBody['id'] ?? ''); } public function getComment(string $owner, string $repositoryName, string $commentId): string { - throw new Exception("Not implemented yet"); + $url = "/repos/{$owner}/{$repositoryName}/issues/comments/{$commentId}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseBody = $response['body'] ?? []; + + return $responseBody['body'] ?? ''; } public function updateComment(string $owner, string $repositoryName, int $commentId, string $comment): string { - throw new Exception("Not implemented yet"); + $url = "/repos/{$owner}/{$repositoryName}/issues/comments/{$commentId}"; + + $response = $this->call(self::METHOD_PATCH, $url, ['Authorization' => "token $this->accessToken"], ['body' => $comment]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to update comment: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + if (!array_key_exists('id', $responseBody)) { + throw new Exception("Comment update response is missing comment ID."); + } + + return (string) ($responseBody['id'] ?? ''); } public function getUser(string $username): array @@ -374,12 +462,35 @@ public function getOwnerName(string $installationId): string public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array { - throw new Exception("Not implemented yet"); + $url = "/repos/{$owner}/{$repositoryName}/pulls/{$pullRequestNumber}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get pull request: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; } public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array { - throw new Exception("Not implemented yet"); + + $url = "/repos/{$owner}/{$repositoryName}/pulls?state=open&head=" . urlencode($branch); + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to list pull requests: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + return $responseBody[0] ?? []; } public function listBranches(string $owner, string $repositoryName): array diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 74af211f..9038fcee 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -344,6 +344,7 @@ public function testGetPullRequest(): void public function testGenerateCloneCommand(): void { + \exec('rm -rf /tmp/clone-branch'); $gitCloneCommand = $this->vcsAdapter->generateCloneCommand('test-kh', 'test2', 'test', GitHub::CLONE_TYPE_BRANCH, '/tmp/clone-branch', '*'); $this->assertNotEmpty($gitCloneCommand); $this->assertStringContainsString('sparse-checkout', $gitCloneCommand); @@ -358,6 +359,7 @@ public function testGenerateCloneCommand(): void public function testGenerateCloneCommandWithCommitHash(): void { + \exec('rm -rf /tmp/clone-commit'); $gitCloneCommand = $this->vcsAdapter->generateCloneCommand('test-kh', 'test2', '4fb10447faea8a55c5cad7b5ebdfdbedca349fe4', GitHub::CLONE_TYPE_COMMIT, '/tmp/clone-commit', '*'); $this->assertNotEmpty($gitCloneCommand); $this->assertStringContainsString('sparse-checkout', $gitCloneCommand); @@ -372,6 +374,7 @@ public function testGenerateCloneCommandWithCommitHash(): void public function testGenerateCloneCommandWithTag(): void { + \exec('rm -rf /tmp/clone-tag /tmp/clone-tag2 /tmp/clone-tag3'); $gitCloneCommand = $this->vcsAdapter->generateCloneCommand('test-kh', 'test2', '0.1.0', GitHub::CLONE_TYPE_TAG, '/tmp/clone-tag', '*'); $this->assertNotEmpty($gitCloneCommand); $this->assertStringContainsString('sparse-checkout', $gitCloneCommand); @@ -439,8 +442,8 @@ public function testGetCommit(): void public function testGetLatestCommit(): void { $commitDetails = $this->vcsAdapter->getLatestCommit('test-kh', 'test1', 'test'); - $this->assertSame('Khushboo Verma', $commitDetails['commitAuthor']); - $this->assertSame('https://avatars.githubusercontent.com/u/43381712?v=4', $commitDetails['commitAuthorAvatar']); - $this->assertSame('https://github.com/vermakhushboo', $commitDetails['commitAuthorUrl']); + $this->assertSame('appwritedemoapp[bot]', $commitDetails['commitAuthor']); + $this->assertSame('https://avatars.githubusercontent.com/in/287220?v=4', $commitDetails['commitAuthorAvatar']); + $this->assertSame('https://github.com/apps/appwritedemoapp', $commitDetails['commitAuthorUrl']); } } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index be115b90..4cf47237 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -98,9 +98,106 @@ public function testCreatePrivateRepository(): void $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); } + public function testCommentWorkflow(): void + { + $repositoryName = 'test-comment-workflow-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'comment-test', 'main'); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'test.txt', 'test', 'Add test file', 'comment-test'); + + $pr = $this->vcsAdapter->createPullRequest( + self::$owner, + $repositoryName, + 'Comment Test PR', + 'comment-test', + 'main' + ); + + $prNumber = $pr['number'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + $originalComment = 'This is a test comment'; + $commentId = $this->vcsAdapter->createComment(self::$owner, $repositoryName, $prNumber, $originalComment); + + $this->assertNotEmpty($commentId); + $this->assertIsString($commentId); + + $retrievedComment = $this->vcsAdapter->getComment(self::$owner, $repositoryName, $commentId); + $this->assertSame($originalComment, $retrievedComment); + + $updatedCommentText = 'This comment has been updated'; + $updatedCommentId = $this->vcsAdapter->updateComment(self::$owner, $repositoryName, (int)$commentId, $updatedCommentText); + + $this->assertSame($commentId, $updatedCommentId); + + $finalComment = $this->vcsAdapter->getComment(self::$owner, $repositoryName, $commentId); + $this->assertSame($updatedCommentText, $finalComment); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + public function testGetComment(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-get-comment-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'test-branch', 'main'); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'test.txt', 'test', 'Add test', 'test-branch'); + + // Create PR + $pr = $this->vcsAdapter->createPullRequest( + self::$owner, + $repositoryName, + 'Test PR', + 'test-branch', + 'main' + ); + + $prNumber = $pr['number'] ?? 0; + + // Create a comment + $commentId = $this->vcsAdapter->createComment(self::$owner, $repositoryName, $prNumber, 'Test comment'); + + // Test getComment + $result = $this->vcsAdapter->getComment(self::$owner, $repositoryName, $commentId); + + $this->assertIsString($result); + $this->assertSame('Test comment', $result); + + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + + public function testCreateCommentInvalidPR(): void + { + $repositoryName = 'test-comment-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->createComment(self::$owner, $repositoryName, 99999, 'Test comment'); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + + public function testGetCommentInvalidId(): void + { + $repositoryName = 'test-get-comment-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + $result = $this->vcsAdapter->getComment(self::$owner, $repositoryName, '99999999'); + + $this->assertIsString($result); + $this->assertSame('', $result); + + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); } public function testGetRepositoryTreeWithSlashInBranchName(): void @@ -340,7 +437,55 @@ public function testListRepositoryContentsNonExistingPath(): void public function testGetPullRequest(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-get-pull-request-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-branch', 'main'); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'feature.txt', 'feature content', 'Add feature', 'feature-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + self::$owner, + $repositoryName, + 'Test PR', + 'feature-branch', + 'main', + 'Test PR description' + ); + + $prNumber = $pr['number'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + // Now test getPullRequest + $result = $this->vcsAdapter->getPullRequest(self::$owner, $repositoryName, $prNumber); + + $this->assertIsArray($result); + $this->assertArrayHasKey('number', $result); + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('state', $result); + $this->assertArrayHasKey('head', $result); + $this->assertArrayHasKey('base', $result); + + $this->assertSame($prNumber, $result['number']); + $this->assertSame('Test PR', $result['title']); + $this->assertSame('open', $result['state']); + + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + + public function testGetPullRequestWithInvalidNumber(): void + { + $repositoryName = 'test-get-pull-request-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getPullRequest(self::$owner, $repositoryName, 99999); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } public function testGenerateCloneCommand(): void @@ -505,7 +650,52 @@ public function testGetOwnerName(): void public function testGetPullRequestFromBranch(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-get-pr-from-branch-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'my-feature', 'main'); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'feature.txt', 'content', 'Add feature', 'my-feature'); + + // Create PR + $pr = $this->vcsAdapter->createPullRequest( + self::$owner, + $repositoryName, + 'Feature PR', + 'my-feature', + 'main' + ); + + $this->assertArrayHasKey('number', $pr); + + // Test getPullRequestFromBranch + $result = $this->vcsAdapter->getPullRequestFromBranch(self::$owner, $repositoryName, 'my-feature'); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertArrayHasKey('head', $result); + + $resultHead = $result['head'] ?? []; + $this->assertSame('my-feature', $resultHead['ref'] ?? ''); + + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + + public function testGetPullRequestFromBranchNoPR(): void + { + $repositoryName = 'test-get-pr-no-pr-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'lonely-branch', 'main'); + + // Don't create a PR - just test the method + $result = $this->vcsAdapter->getPullRequestFromBranch(self::$owner, $repositoryName, 'lonely-branch'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); } public function testCreateComment(): void @@ -513,6 +703,61 @@ public function testCreateComment(): void $this->markTestSkipped('Will be implemented in follow-up PR'); } + public function testCreateFile(): void + { + $repositoryName = 'test-create-file-'.\uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $result = $this->vcsAdapter->createFile( + self::$owner, + $repositoryName, + 'test.md', + '# Test', + 'Add test file' + ); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + + public function testCreateFileOnBranch(): void + { + $repositoryName = 'test-create-file-branch-'.\uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Main'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature', 'main'); + + // Create file on specific branch + $result = $this->vcsAdapter->createFile( + self::$owner, + $repositoryName, + 'feature.md', + '# Feature', + 'Add feature file', + 'feature' // ← Branch parameter + ); + + $this->assertIsArray($result); + + // Verify it's on the right branch + $content = $this->vcsAdapter->getRepositoryContent( + self::$owner, + $repositoryName, + 'feature.md', + 'feature' + ); + $this->assertSame('# Feature', $content['content']); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + public function testListBranches(): void { $this->markTestSkipped('Will be implemented in follow-up PR');