From 6ac515d80e5a10d62b02a97c3435ad0c6c0f04f0 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 14:40:17 +0000 Subject: [PATCH] feat: add AssignAgent, ManagePullRequest, ReportToIssue actions AssignAgent activates a plan and starts an agent session. ManagePullRequest evaluates PR state/CI checks and merges when ready. ReportToIssue posts progress comments on Forgejo issues. Co-Authored-By: Virgil --- Actions/Forge/AssignAgent.php | 40 ++++++++++ Actions/Forge/ManagePullRequest.php | 59 +++++++++++++++ Actions/Forge/ReportToIssue.php | 34 +++++++++ Services/ForgejoService.php | 7 ++ tests/Feature/ForgeActionsTest.php | 112 ++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 Actions/Forge/AssignAgent.php create mode 100644 Actions/Forge/ManagePullRequest.php create mode 100644 Actions/Forge/ReportToIssue.php create mode 100644 tests/Feature/ForgeActionsTest.php diff --git a/Actions/Forge/AssignAgent.php b/Actions/Forge/AssignAgent.php new file mode 100644 index 0000000..9a2eb08 --- /dev/null +++ b/Actions/Forge/AssignAgent.php @@ -0,0 +1,40 @@ +status !== AgentPlan::STATUS_ACTIVE) { + $plan->activate(); + } + + return StartSession::run($agentType, $plan->slug, $workspaceId); + } +} diff --git a/Actions/Forge/ManagePullRequest.php b/Actions/Forge/ManagePullRequest.php new file mode 100644 index 0000000..4a606b6 --- /dev/null +++ b/Actions/Forge/ManagePullRequest.php @@ -0,0 +1,59 @@ +getPullRequest($owner, $repo, $prNumber); + + if (($pr['state'] ?? '') !== 'open') { + return ['merged' => false, 'reason' => 'not_open']; + } + + if (empty($pr['mergeable'])) { + return ['merged' => false, 'reason' => 'conflicts']; + } + + $headSha = $pr['head']['sha'] ?? ''; + $status = $forge->getCombinedStatus($owner, $repo, $headSha); + + if (($status['state'] ?? '') !== 'success') { + return ['merged' => false, 'reason' => 'checks_pending']; + } + + $forge->mergePullRequest($owner, $repo, $prNumber); + + return ['merged' => true, 'pr_number' => $prNumber]; + } +} diff --git a/Actions/Forge/ReportToIssue.php b/Actions/Forge/ReportToIssue.php new file mode 100644 index 0000000..7e143e1 --- /dev/null +++ b/Actions/Forge/ReportToIssue.php @@ -0,0 +1,34 @@ +createComment($owner, $repo, $issueNumber, $message); + } +} diff --git a/Services/ForgejoService.php b/Services/ForgejoService.php index e255428..efa3f51 100644 --- a/Services/ForgejoService.php +++ b/Services/ForgejoService.php @@ -1,5 +1,12 @@ workspace = Workspace::factory()->create(); + + $this->service = new ForgejoService( + baseUrl: 'https://forge.example.com', + token: 'test-token-abc', + ); + + $this->app->instance(ForgejoService::class, $this->service); +}); + +it('assigns an agent to a plan and starts a session', function () { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_DRAFT, + ]); + + $session = AssignAgent::run($plan, 'opus', $this->workspace->id); + + expect($session)->toBeInstanceOf(AgentSession::class); + expect($session->agent_type)->toBe('opus'); + expect($session->agent_plan_id)->toBe($plan->id); + + // Plan should be activated + $plan->refresh(); + expect($plan->status)->toBe(AgentPlan::STATUS_ACTIVE); +}); + +it('reports progress to a Forgejo issue', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues/5/comments' => Http::response([ + 'id' => 1, + 'body' => 'Progress update: phase 1 complete.', + ]), + ]); + + ReportToIssue::run('core', 'app', 5, 'Progress update: phase 1 complete.'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/repos/core/app/issues/5/comments') + && $request['body'] === 'Progress update: phase 1 complete.'; + }); +}); + +it('merges a PR when checks pass', function () { + Http::fake([ + // Get PR — open and mergeable + 'forge.example.com/api/v1/repos/core/app/pulls/10' => Http::response([ + 'number' => 10, + 'state' => 'open', + 'mergeable' => true, + 'head' => ['sha' => 'abc123'], + ]), + + // Combined status — success + 'forge.example.com/api/v1/repos/core/app/commits/abc123/status' => Http::response([ + 'state' => 'success', + ]), + + // Merge + 'forge.example.com/api/v1/repos/core/app/pulls/10/merge' => Http::response([], 200), + ]); + + $result = ManagePullRequest::run('core', 'app', 10); + + expect($result)->toMatchArray([ + 'merged' => true, + 'pr_number' => 10, + ]); +}); + +it('does not merge PR when checks are pending', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/pulls/10' => Http::response([ + 'number' => 10, + 'state' => 'open', + 'mergeable' => true, + 'head' => ['sha' => 'abc123'], + ]), + + 'forge.example.com/api/v1/repos/core/app/commits/abc123/status' => Http::response([ + 'state' => 'pending', + ]), + ]); + + $result = ManagePullRequest::run('core', 'app', 10); + + expect($result)->toMatchArray([ + 'merged' => false, + 'reason' => 'checks_pending', + ]); +});