feat: add ForgejoService API client for agent orchestration
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
440ea340df
commit
e82d35c13d
2 changed files with 367 additions and 0 deletions
196
Services/ForgejoService.php
Normal file
196
Services/ForgejoService.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Forgejo REST API client for agent orchestration.
|
||||
*
|
||||
* Wraps the Forgejo v1 API for issue management, pull requests,
|
||||
* commit statuses, and branch operations.
|
||||
*/
|
||||
class ForgejoService
|
||||
{
|
||||
public function __construct(
|
||||
private string $baseUrl,
|
||||
private string $token,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List issues for a repository.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<int> $labelIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $query
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
171
tests/Feature/ForgejoServiceTest.php
Normal file
171
tests/Feature/ForgejoServiceTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
use Core\Mod\Agentic\Services\ForgejoService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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];
|
||||
});
|
||||
});
|
||||
Reference in a new issue