From e82d35c13d1e7a8fca6e9833703fa360fb17f1a0 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 14:34:20 +0000 Subject: [PATCH] feat: add ForgejoService API client for agent orchestration Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- Services/ForgejoService.php | 196 +++++++++++++++++++++++++++ tests/Feature/ForgejoServiceTest.php | 171 +++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 Services/ForgejoService.php create mode 100644 tests/Feature/ForgejoServiceTest.php diff --git a/Services/ForgejoService.php b/Services/ForgejoService.php new file mode 100644 index 0000000..e255428 --- /dev/null +++ b/Services/ForgejoService.php @@ -0,0 +1,196 @@ +> + */ + public function listIssues(string $owner, string $repo, string $state = 'open', ?string $label = null): array + { + $query = ['state' => $state, 'type' => 'issues']; + + if ($label !== null) { + $query['labels'] = $label; + } + + return $this->get("/repos/{$owner}/{$repo}/issues", $query); + } + + /** + * Get a single issue by number. + * + * @return array + */ + public function getIssue(string $owner, string $repo, int $number): array + { + return $this->get("/repos/{$owner}/{$repo}/issues/{$number}"); + } + + /** + * Create a comment on an issue. + * + * @return array + */ + public function createComment(string $owner, string $repo, int $issueNumber, string $body): array + { + return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", [ + 'body' => $body, + ]); + } + + /** + * Add labels to an issue. + * + * @param array $labelIds + * @return array> + */ + public function addLabels(string $owner, string $repo, int $issueNumber, array $labelIds): array + { + return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels", [ + 'labels' => $labelIds, + ]); + } + + /** + * List pull requests for a repository. + * + * @return array> + */ + public function listPullRequests(string $owner, string $repo, string $state = 'all'): array + { + return $this->get("/repos/{$owner}/{$repo}/pulls", ['state' => $state]); + } + + /** + * Get a single pull request by number. + * + * @return array + */ + public function getPullRequest(string $owner, string $repo, int $number): array + { + return $this->get("/repos/{$owner}/{$repo}/pulls/{$number}"); + } + + /** + * Get the combined commit status for a ref. + * + * @return array + */ + public function getCombinedStatus(string $owner, string $repo, string $sha): array + { + return $this->get("/repos/{$owner}/{$repo}/commits/{$sha}/status"); + } + + /** + * Merge a pull request. + * + * @param string $method One of: merge, rebase, rebase-merge, squash, fast-forward-only + * + * @throws \RuntimeException + */ + public function mergePullRequest(string $owner, string $repo, int $number, string $method = 'merge'): void + { + $response = $this->request() + ->post($this->url("/repos/{$owner}/{$repo}/pulls/{$number}/merge"), [ + 'Do' => $method, + ]); + + if (! $response->successful()) { + throw new \RuntimeException( + "Failed to merge PR #{$number}: {$response->status()} {$response->body()}" + ); + } + } + + /** + * Create a branch in a repository. + * + * @return array + */ + public function createBranch(string $owner, string $repo, string $name, string $from = 'main'): array + { + return $this->post("/repos/{$owner}/{$repo}/branches", [ + 'new_branch_name' => $name, + 'old_branch_name' => $from, + ]); + } + + /** + * Build an authenticated HTTP client. + */ + private function request(): \Illuminate\Http\Client\PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->timeout(15); + } + + /** + * Build the full API URL for a path. + */ + private function url(string $path): string + { + return "{$this->baseUrl}/api/v1{$path}"; + } + + /** + * Perform a GET request and return decoded JSON. + * + * @param array $query + * @return array + * + * @throws \RuntimeException + */ + private function get(string $path, array $query = []): array + { + $response = $this->request()->get($this->url($path), $query); + + if (! $response->successful()) { + throw new \RuntimeException( + "Forgejo API GET {$path} failed: {$response->status()}" + ); + } + + return $response->json(); + } + + /** + * Perform a POST request and return decoded JSON. + * + * @param array $data + * @return array + * + * @throws \RuntimeException + */ + private function post(string $path, array $data = []): array + { + $response = $this->request()->post($this->url($path), $data); + + if (! $response->successful()) { + throw new \RuntimeException( + "Forgejo API POST {$path} failed: {$response->status()}" + ); + } + + return $response->json(); + } +} diff --git a/tests/Feature/ForgejoServiceTest.php b/tests/Feature/ForgejoServiceTest.php new file mode 100644 index 0000000..5329097 --- /dev/null +++ b/tests/Feature/ForgejoServiceTest.php @@ -0,0 +1,171 @@ +service = new ForgejoService( + baseUrl: 'https://forge.example.com', + token: 'test-token-abc', + ); +}); + +it('sends bearer token on every request', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues*' => Http::response([]), + ]); + + $this->service->listIssues('core', 'app'); + + Http::assertSent(function ($request) { + return $request->hasHeader('Authorization', 'Bearer test-token-abc'); + }); +}); + +it('fetches open issues', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues*' => Http::response([ + ['id' => 1, 'number' => 1, 'title' => 'Fix the widget'], + ['id' => 2, 'number' => 2, 'title' => 'Add colour picker'], + ]), + ]); + + $issues = $this->service->listIssues('core', 'app'); + + expect($issues)->toBeArray()->toHaveCount(2); + expect($issues[0]['title'])->toBe('Fix the widget'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'state=open') + && str_contains($request->url(), '/repos/core/app/issues'); + }); +}); + +it('fetches issues filtered by label', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues*' => Http::response([ + ['id' => 3, 'number' => 3, 'title' => 'Labelled issue'], + ]), + ]); + + $this->service->listIssues('core', 'app', 'open', 'bug'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'labels=bug'); + }); +}); + +it('creates an issue comment', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues/5/comments' => Http::response([ + 'id' => 42, + 'body' => 'Agent analysis complete.', + ], 201), + ]); + + $comment = $this->service->createComment('core', 'app', 5, 'Agent analysis complete.'); + + expect($comment)->toBeArray(); + expect($comment['body'])->toBe('Agent analysis complete.'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/issues/5/comments') + && $request['body'] === 'Agent analysis complete.'; + }); +}); + +it('lists pull requests', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/pulls*' => Http::response([ + ['id' => 10, 'number' => 10, 'title' => 'Feature branch'], + ]), + ]); + + $prs = $this->service->listPullRequests('core', 'app', 'open'); + + expect($prs)->toBeArray()->toHaveCount(1); + expect($prs[0]['title'])->toBe('Feature branch'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'state=open') + && str_contains($request->url(), '/repos/core/app/pulls'); + }); +}); + +it('gets combined commit status', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/commits/abc123/status' => Http::response([ + 'state' => 'success', + 'statuses' => [ + ['context' => 'ci/tests', 'status' => 'success'], + ], + ]), + ]); + + $status = $this->service->getCombinedStatus('core', 'app', 'abc123'); + + expect($status['state'])->toBe('success'); + expect($status['statuses'])->toHaveCount(1); +}); + +it('merges a pull request', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/pulls/7/merge' => Http::response(null, 200), + ]); + + $this->service->mergePullRequest('core', 'app', 7, 'squash'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/pulls/7/merge') + && $request['Do'] === 'squash'; + }); +}); + +it('throws on failed merge', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/pulls/7/merge' => Http::response( + ['message' => 'not mergeable'], + 405, + ), + ]); + + $this->service->mergePullRequest('core', 'app', 7); +})->throws(RuntimeException::class); + +it('creates a branch', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/branches' => Http::response([ + 'name' => 'agent/fix-123', + ], 201), + ]); + + $branch = $this->service->createBranch('core', 'app', 'agent/fix-123', 'main'); + + expect($branch['name'])->toBe('agent/fix-123'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request['new_branch_name'] === 'agent/fix-123' + && $request['old_branch_name'] === 'main'; + }); +}); + +it('adds labels to an issue', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues/3/labels' => Http::response([ + ['id' => 1, 'name' => 'bug'], + ['id' => 2, 'name' => 'priority'], + ]), + ]); + + $labels = $this->service->addLabels('core', 'app', 3, [1, 2]); + + expect($labels)->toBeArray()->toHaveCount(2); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request['labels'] === [1, 2]; + }); +});