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:
parent
b3cf2a4b7d
commit
08d397fbf6
2 changed files with 199 additions and 0 deletions
102
Actions/Forge/CreatePlanFromIssue.php
Normal file
102
Actions/Forge/CreatePlanFromIssue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
97
tests/Feature/CreatePlanFromIssueTest.php
Normal file
97
tests/Feature/CreatePlanFromIssueTest.php
Normal 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);
|
||||
});
|
||||
Reference in a new issue