Merge pull request 'feat(agentic): Forgejo integration bridge — PHP service linking platform to forges' (#150) from feat/agentic-forgejo-bridge into new

This commit is contained in:
Charon (snider-linux) 2026-02-12 20:06:58 +00:00
commit 6702d56edb
6 changed files with 987 additions and 0 deletions

View file

@ -4,12 +4,29 @@ declare(strict_types=1);
namespace App\Providers;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
/** @var array<string, mixed> $config */
$config = $app['config']->get('forgejo', []);
return new ForgejoService(
instances: $config['instances'] ?? [],
defaultInstance: $config['default'] ?? 'forge',
timeout: $config['timeout'] ?? 30,
retryTimes: $config['retry_times'] ?? 3,
retrySleep: $config['retry_sleep'] ?? 500,
);
});
}
public function boot(): void
{
// Auto-migrate on first boot. Single-user desktop app with

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Low-level HTTP client for a single Forgejo instance.
*
* Wraps the Laravel HTTP client with token auth, retry, and
* base-URL scoping so callers never deal with raw HTTP details.
*/
class ForgejoClient
{
private PendingRequest $http;
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
int $timeout = 30,
int $retryTimes = 3,
int $retrySleep = 500,
) {
if ($this->token === '') {
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
}
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
->withHeaders([
'Authorization' => "token {$this->token}",
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])
->timeout($timeout)
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
$e instanceof \Illuminate\Http\Client\ConnectionException
);
}
public function baseUrl(): string
{
return $this->baseUrl;
}
// ----- Generic verbs -----
/** @return array<string, mixed> */
public function get(string $path, array $query = []): array
{
return $this->decodeOrFail($this->http->get($path, $query));
}
/** @return array<string, mixed> */
public function post(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->post($path, $data));
}
/** @return array<string, mixed> */
public function patch(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->patch($path, $data));
}
/** @return array<string, mixed> */
public function put(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->put($path, $data));
}
public function delete(string $path): void
{
$response = $this->http->delete($path);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
);
}
}
/**
* GET a path and return the raw response body as a string.
* Useful for endpoints that return non-JSON content (e.g. diffs).
*/
public function getRaw(string $path, array $query = []): string
{
$response = $this->http->get($path, $query);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
);
}
return $response->body();
}
/**
* Paginate through all pages of a list endpoint.
*
* @return list<array<string, mixed>>
*/
public function paginate(string $path, array $query = [], int $limit = 50): array
{
$all = [];
$page = 1;
do {
$response = $this->http->get($path, array_merge($query, [
'page' => $page,
'limit' => $limit,
]));
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
);
}
$items = $response->json();
if (!is_array($items) || $items === []) {
break;
}
array_push($all, ...$items);
// Forgejo returns total count in x-total-count header.
$total = (int) $response->header('x-total-count');
$page++;
} while (count($all) < $total);
return $all;
}
// ----- Internals -----
/** @return array<string, mixed> */
private function decodeOrFail(Response $response): array
{
if ($response->failed()) {
throw new RuntimeException(
"Forgejo API error [{$response->status()}]: {$response->body()}"
);
}
return $response->json() ?? [];
}
}

View file

@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use RuntimeException;
/**
* Business-logic layer for Forgejo operations.
*
* Manages multiple Forgejo instances (forge, dev, qa) and provides
* a unified API for issues, pull requests, repositories, and user
* management. Mirrors the Go pkg/forge API surface.
*/
class ForgejoService
{
/** @var array<string, ForgejoClient> */
private array $clients = [];
private string $defaultInstance;
/**
* @param array<string, array{url: string, token: string}> $instances
*/
public function __construct(
array $instances,
string $defaultInstance = 'forge',
private readonly int $timeout = 30,
private readonly int $retryTimes = 3,
private readonly int $retrySleep = 500,
) {
$this->defaultInstance = $defaultInstance;
foreach ($instances as $name => $cfg) {
if (($cfg['token'] ?? '') === '') {
continue; // skip unconfigured instances
}
$this->clients[$name] = new ForgejoClient(
baseUrl: $cfg['url'],
token: $cfg['token'],
timeout: $this->timeout,
retryTimes: $this->retryTimes,
retrySleep: $this->retrySleep,
);
}
}
// ----------------------------------------------------------------
// Instance resolution
// ----------------------------------------------------------------
public function client(?string $instance = null): ForgejoClient
{
$name = $instance ?? $this->defaultInstance;
return $this->clients[$name]
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
}
/** @return list<string> */
public function instances(): array
{
return array_keys($this->clients);
}
// ----------------------------------------------------------------
// Issue Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createIssue(
string $owner,
string $repo,
string $title,
string $body = '',
array $labels = [],
string $assignee = '',
?string $instance = null,
): array {
$data = ['title' => $title, 'body' => $body];
if ($labels !== []) {
$data['labels'] = $labels;
}
if ($assignee !== '') {
$data['assignees'] = [$assignee];
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
}
/** @return array<string, mixed> */
public function updateIssue(
string $owner,
string $repo,
int $number,
array $fields,
?string $instance = null,
): array {
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
}
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
{
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
}
/** @return array<string, mixed> */
public function addComment(
string $owner,
string $repo,
int $number,
string $body,
?string $instance = null,
): array {
return $this->client($instance)->post(
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
['body' => $body],
);
}
/**
* @return list<array<string, mixed>>
*/
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
int $page = 1,
int $limit = 50,
?string $instance = null,
): array {
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
'state' => $state,
'type' => 'issues',
'page' => $page,
'limit' => $limit,
]);
}
// ----------------------------------------------------------------
// Pull Request Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createPR(
string $owner,
string $repo,
string $head,
string $base,
string $title,
string $body = '',
?string $instance = null,
): array {
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
'head' => $head,
'base' => $base,
'title' => $title,
'body' => $body,
]);
}
public function mergePR(
string $owner,
string $repo,
int $number,
string $strategy = 'merge',
?string $instance = null,
): void {
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
'Do' => $strategy,
'delete_branch_after_merge' => true,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function listPRs(
string $owner,
string $repo,
string $state = 'open',
?string $instance = null,
): array {
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
'state' => $state,
]);
}
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
{
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
}
// ----------------------------------------------------------------
// Repository Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listRepos(string $org, ?string $instance = null): array
{
return $this->client($instance)->paginate("/orgs/{$org}/repos");
}
/** @return array<string, mixed> */
public function getRepo(string $owner, string $name, ?string $instance = null): array
{
return $this->client($instance)->get("/repos/{$owner}/{$name}");
}
/** @return array<string, mixed> */
public function createBranch(
string $owner,
string $repo,
string $name,
string $from = '',
?string $instance = null,
): array {
$data = ['new_branch_name' => $name];
if ($from !== '') {
$data['old_branch_name'] = $from;
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
}
public function deleteBranch(
string $owner,
string $repo,
string $name,
?string $instance = null,
): void {
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
}
// ----------------------------------------------------------------
// User / Token Management
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createUser(
string $username,
string $email,
string $password,
?string $instance = null,
): array {
return $this->client($instance)->post('/admin/users', [
'username' => $username,
'email' => $email,
'password' => $password,
'must_change_password' => false,
]);
}
/** @return array<string, mixed> */
public function createToken(
string $username,
string $name,
array $scopes = [],
?string $instance = null,
): array {
$data = ['name' => $name];
if ($scopes !== []) {
$data['scopes'] = $scopes;
}
return $this->client($instance)->post("/users/{$username}/tokens", $data);
}
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
{
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
}
/** @return array<string, mixed> */
public function addToOrg(
string $username,
string $org,
int $teamId,
?string $instance = null,
): array {
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
}
// ----------------------------------------------------------------
// Org Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listOrgs(?string $instance = null): array
{
return $this->client($instance)->paginate('/user/orgs');
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Forgejo Instance
|--------------------------------------------------------------------------
|
| The instance name to use when no explicit instance is specified.
|
*/
'default' => env('FORGEJO_DEFAULT', 'forge'),
/*
|--------------------------------------------------------------------------
| Forgejo Instances
|--------------------------------------------------------------------------
|
| Each entry defines a Forgejo instance the platform can talk to.
| The service auto-routes by matching the configured URL.
|
| url Base URL of the Forgejo instance (no trailing slash)
| token Admin API token for the instance
|
*/
'instances' => [
'forge' => [
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
'token' => env('FORGEJO_FORGE_TOKEN', ''),
],
'dev' => [
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
'token' => env('FORGEJO_DEV_TOKEN', ''),
],
'qa' => [
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
'token' => env('FORGEJO_QA_TOKEN', ''),
],
],
/*
|--------------------------------------------------------------------------
| HTTP Client Settings
|--------------------------------------------------------------------------
*/
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
];

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoClient;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoClientTest extends TestCase
{
private const BASE_URL = 'https://forge.test';
private const TOKEN = 'test-token-abc123';
// ---- Construction ----
public function test_constructor_good(): void
{
Http::fake();
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
$this->assertSame(self::BASE_URL, $client->baseUrl());
}
public function test_constructor_bad_empty_token(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API token is required');
new ForgejoClient(self::BASE_URL, '');
}
// ---- GET ----
public function test_get_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->get('/repos/owner/repo');
$this->assertSame(1, $result['id']);
$this->assertSame('repo', $result['name']);
}
public function test_get_bad_server_error(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Forgejo API error [500]');
$client->get('/repos/owner/repo');
}
// ---- POST ----
public function test_post_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
$this->assertSame(42, $result['number']);
}
// ---- PATCH ----
public function test_patch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
$this->assertSame('closed', $result['state']);
}
// ---- PUT ----
public function test_put_good(): void
{
Http::fake([
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->put('/teams/5/members/alice');
$this->assertIsArray($result);
}
// ---- DELETE ----
public function test_delete_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
// Should not throw
$client->delete('/repos/owner/repo/branches/old');
$this->assertTrue(true);
}
public function test_delete_bad_not_found(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('failed [404]');
$client->delete('/repos/owner/repo/branches/gone');
}
// ---- getRaw ----
public function test_getRaw_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
"diff --git a/file.txt b/file.txt\n",
200,
['Content-Type' => 'text/plain'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Pagination ----
public function test_paginate_good(): void
{
Http::fake([
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
[['id' => 1], ['id' => 2]],
200,
['x-total-count' => '3'],
),
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
[['id' => 3]],
200,
['x-total-count' => '3'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
$this->assertCount(3, $repos);
$this->assertSame(1, $repos[0]['id']);
$this->assertSame(3, $repos[2]['id']);
}
public function test_paginate_good_empty(): void
{
Http::fake([
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/empty/repos');
$this->assertSame([], $repos);
}
// ---- Auth header ----
public function test_auth_header_sent(): void
{
Http::fake([
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$client->get('/user');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
});
}
}

View file

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoServiceTest extends TestCase
{
private const INSTANCES = [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
];
private function service(): ForgejoService
{
return new ForgejoService(
instances: self::INSTANCES,
defaultInstance: 'forge',
timeout: 5,
retryTimes: 0,
retrySleep: 0,
);
}
// ---- Instance management ----
public function test_instances_good(): void
{
$svc = $this->service();
$this->assertSame(['forge', 'dev'], $svc->instances());
}
public function test_instances_skips_empty_token(): void
{
$svc = new ForgejoService(
instances: [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
'qa' => ['url' => 'https://qa.test', 'token' => ''],
],
);
$this->assertSame(['forge'], $svc->instances());
}
public function test_client_bad_unknown_instance(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("instance 'nope' is not configured");
$this->service()->client('nope');
}
// ---- Issues ----
public function test_createIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
'number' => 99,
'title' => 'New bug',
], 201),
]);
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
$this->assertSame(99, $result['number']);
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
}
public function test_createIssue_good_with_labels_and_assignee(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
]);
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
}
public function test_closeIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
]);
$result = $this->service()->closeIssue('org', 'repo', 5);
$this->assertSame('closed', $result['state']);
}
public function test_addComment_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
]);
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
$this->assertSame(100, $result['id']);
}
public function test_listIssues_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
['number' => 1],
['number' => 2],
], 200),
]);
$issues = $this->service()->listIssues('org', 'repo');
$this->assertCount(2, $issues);
}
// ---- Pull Requests ----
public function test_createPR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
'number' => 10,
'title' => 'Feature X',
], 201),
]);
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
$this->assertSame(10, $result['number']);
}
public function test_mergePR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
]);
// Should not throw
$this->service()->mergePR('org', 'repo', 10, 'squash');
$this->assertTrue(true);
}
public function test_getPRDiff_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
"diff --git a/f.go b/f.go\n+new line\n",
200,
),
]);
$diff = $this->service()->getPRDiff('org', 'repo', 10);
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Repositories ----
public function test_getRepo_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
]);
$result = $this->service()->getRepo('org', 'core');
$this->assertSame('org/core', $result['full_name']);
}
public function test_createBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
]);
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
$this->assertSame('feat/y', $result['name']);
Http::assertSent(fn ($r) =>
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
);
}
public function test_deleteBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
]);
$this->service()->deleteBranch('org', 'repo', 'old');
$this->assertTrue(true);
}
// ---- User / Token Management ----
public function test_createUser_good(): void
{
Http::fake([
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
]);
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
$this->assertSame('bot', $result['login']);
Http::assertSent(fn ($r) =>
$r['username'] === 'bot'
&& $r['must_change_password'] === false
);
}
public function test_createToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
]);
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
$this->assertSame('abc123', $result['sha1']);
}
public function test_revokeToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
]);
$this->service()->revokeToken('bot', 42);
$this->assertTrue(true);
}
// ---- Multi-instance routing ----
public function test_explicit_instance_routing(): void
{
Http::fake([
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
]);
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
$this->assertSame('org/repo', $result['full_name']);
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
}
}