feat: add CreatePlanFromIssue action

Converts Forgejo work items (from ScanForWork) into AgentPlans.
Extracts checklist tasks from issue body, creates a single-phase plan,
and deduplicates by matching issue metadata on existing plans.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 14:40:10 +00:00
parent b3cf2a4b7d
commit 08d397fbf6
2 changed files with 199 additions and 0 deletions

View file

@ -0,0 +1,102 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Convert a Forgejo work item into an AgentPlan.
*
* Accepts the structured work item array produced by ScanForWork,
* extracts checklist tasks from the issue body, and creates a plan
* with a single phase. Returns an existing plan if one already
* matches the same issue.
*
* Usage:
* $plan = CreatePlanFromIssue::run($workItem, $workspaceId);
*/
class CreatePlanFromIssue
{
use Action;
/**
* @param array{epic_number: int, issue_number: int, issue_title: string, issue_body: string, assignee: string|null, repo_owner: string, repo_name: string, needs_coding: bool, has_pr: bool} $workItem
*/
public function handle(array $workItem, int $workspaceId): AgentPlan
{
$issueNumber = (int) $workItem['issue_number'];
$owner = (string) $workItem['repo_owner'];
$repo = (string) $workItem['repo_name'];
// Check for an existing plan for this issue (not archived)
$existing = AgentPlan::where('status', '!=', AgentPlan::STATUS_ARCHIVED)
->whereJsonContains('metadata->issue_number', $issueNumber)
->whereJsonContains('metadata->repo_owner', $owner)
->whereJsonContains('metadata->repo_name', $repo)
->first();
if ($existing !== null) {
return $existing->load('agentPhases');
}
$tasks = $this->extractTasks((string) $workItem['issue_body']);
$plan = CreatePlan::run([
'title' => (string) $workItem['issue_title'],
'slug' => "forge-{$owner}-{$repo}-{$issueNumber}",
'description' => (string) $workItem['issue_body'],
'phases' => [
[
'name' => "Resolve issue #{$issueNumber}",
'description' => "Complete all tasks for issue #{$issueNumber}",
'tasks' => $tasks,
],
],
], $workspaceId);
$plan->update([
'metadata' => [
'source' => 'forgejo',
'epic_number' => (int) $workItem['epic_number'],
'issue_number' => $issueNumber,
'repo_owner' => $owner,
'repo_name' => $repo,
'assignee' => $workItem['assignee'] ?? null,
],
]);
return $plan->load('agentPhases');
}
/**
* Extract task names from markdown checklist items.
*
* Matches lines like `- [ ] Create picker UI` and returns
* just the task name portion.
*
* @return array<int, string>
*/
private function extractTasks(string $body): array
{
$tasks = [];
if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) {
foreach ($matches[1] as $taskName) {
$tasks[] = trim($taskName);
}
}
return $tasks;
}
}

View file

@ -0,0 +1,97 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Mod\Agentic\Actions\Forge\CreatePlanFromIssue;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Tenant\Models\Workspace;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
});
it('creates a plan from a work item', function () {
$workItem = [
'epic_number' => 1,
'issue_number' => 5,
'issue_title' => 'Add colour picker component',
'issue_body' => "## Requirements\n- [ ] Create picker UI\n- [ ] Add validation\n- [ ] Write tests",
'assignee' => 'virgil',
'repo_owner' => 'core',
'repo_name' => 'app',
'needs_coding' => true,
'has_pr' => false,
];
$plan = CreatePlanFromIssue::run($workItem, $this->workspace->id);
expect($plan)->toBeInstanceOf(AgentPlan::class);
expect($plan->title)->toBe('Add colour picker component');
expect($plan->slug)->toBe('forge-core-app-5');
expect($plan->status)->toBe(AgentPlan::STATUS_DRAFT);
expect($plan->metadata)->toMatchArray([
'source' => 'forgejo',
'epic_number' => 1,
'issue_number' => 5,
'repo_owner' => 'core',
'repo_name' => 'app',
'assignee' => 'virgil',
]);
// Verify phases and tasks
expect($plan->agentPhases)->toHaveCount(1);
$phase = $plan->agentPhases->first();
expect($phase->name)->toBe('Resolve issue #5');
expect($phase->tasks)->toHaveCount(3);
expect($phase->tasks[0]['name'])->toBe('Create picker UI');
expect($phase->tasks[1]['name'])->toBe('Add validation');
expect($phase->tasks[2]['name'])->toBe('Write tests');
});
it('creates a plan with no tasks if body has no checklist', function () {
$workItem = [
'epic_number' => 1,
'issue_number' => 7,
'issue_title' => 'Investigate performance regression',
'issue_body' => 'The dashboard is slow. Please investigate.',
'assignee' => null,
'repo_owner' => 'core',
'repo_name' => 'app',
'needs_coding' => true,
'has_pr' => false,
];
$plan = CreatePlanFromIssue::run($workItem, $this->workspace->id);
expect($plan)->toBeInstanceOf(AgentPlan::class);
expect($plan->title)->toBe('Investigate performance regression');
expect($plan->agentPhases)->toHaveCount(1);
expect($plan->agentPhases->first()->tasks)->toBeEmpty();
});
it('skips duplicate plans for same issue', function () {
$workItem = [
'epic_number' => 1,
'issue_number' => 9,
'issue_title' => 'Fix the widget',
'issue_body' => "- [ ] Do the thing",
'assignee' => null,
'repo_owner' => 'core',
'repo_name' => 'app',
'needs_coding' => true,
'has_pr' => false,
];
$first = CreatePlanFromIssue::run($workItem, $this->workspace->id);
$second = CreatePlanFromIssue::run($workItem, $this->workspace->id);
expect($second->id)->toBe($first->id);
expect(AgentPlan::where('slug', 'forge-core-app-9')->count())->toBe(1);
});