feat: add ScanForWork action for Forgejo epic scanning
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e82d35c13d
commit
b3cf2a4b7d
2 changed files with 247 additions and 0 deletions
145
Actions/Forge/ScanForWork.php
Normal file
145
Actions/Forge/ScanForWork.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?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\Services\ForgejoService;
|
||||
|
||||
/**
|
||||
* Scan Forgejo for epic issues and identify unchecked children that need coding.
|
||||
*
|
||||
* Parses epic issue bodies for checklist syntax (`- [ ] #N` / `- [x] #N`),
|
||||
* cross-references with open pull requests, and returns structured work items
|
||||
* for any unchecked child issue that has no linked PR.
|
||||
*
|
||||
* Usage:
|
||||
* $workItems = ScanForWork::run('core', 'app');
|
||||
*/
|
||||
class ScanForWork
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* Scan a repository for actionable work from epic issues.
|
||||
*
|
||||
* @return array<int, 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,
|
||||
* }>
|
||||
*/
|
||||
public function handle(string $owner, string $repo): array
|
||||
{
|
||||
$forge = app(ForgejoService::class);
|
||||
|
||||
$epics = $forge->listIssues($owner, $repo, 'open', 'epic');
|
||||
|
||||
if ($epics === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pullRequests = $forge->listPullRequests($owner, $repo, 'all');
|
||||
$linkedIssues = $this->extractLinkedIssues($pullRequests);
|
||||
|
||||
$workItems = [];
|
||||
|
||||
foreach ($epics as $epic) {
|
||||
$checklist = $this->parseChecklist((string) ($epic['body'] ?? ''));
|
||||
|
||||
foreach ($checklist as $item) {
|
||||
if ($item['checked']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($item['number'], $linkedIssues, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$child = $forge->getIssue($owner, $repo, $item['number']);
|
||||
|
||||
$assignee = null;
|
||||
if (! empty($child['assignees']) && is_array($child['assignees'])) {
|
||||
$assignee = $child['assignees'][0]['login'] ?? null;
|
||||
}
|
||||
|
||||
$workItems[] = [
|
||||
'epic_number' => (int) $epic['number'],
|
||||
'issue_number' => (int) $child['number'],
|
||||
'issue_title' => (string) ($child['title'] ?? ''),
|
||||
'issue_body' => (string) ($child['body'] ?? ''),
|
||||
'assignee' => $assignee,
|
||||
'repo_owner' => $owner,
|
||||
'repo_name' => $repo,
|
||||
'needs_coding' => true,
|
||||
'has_pr' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $workItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a checklist body into structured items.
|
||||
*
|
||||
* Matches lines like `- [ ] #2` (unchecked) and `- [x] #3` (checked).
|
||||
*
|
||||
* @return array<int, array{number: int, checked: bool}>
|
||||
*/
|
||||
private function parseChecklist(string $body): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if (preg_match_all('/- \[([ xX])\] #(\d+)/', $body, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$items[] = [
|
||||
'number' => (int) $match[2],
|
||||
'checked' => $match[1] !== ' ',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract issue numbers referenced in PR bodies.
|
||||
*
|
||||
* Matches common linking patterns: "Closes #N", "Fixes #N", "Resolves #N",
|
||||
* and bare "#N" references.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $pullRequests
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function extractLinkedIssues(array $pullRequests): array
|
||||
{
|
||||
$linked = [];
|
||||
|
||||
foreach ($pullRequests as $pr) {
|
||||
$body = (string) ($pr['body'] ?? '');
|
||||
|
||||
if (preg_match_all('/#(\d+)/', $body, $matches)) {
|
||||
foreach ($matches[1] as $number) {
|
||||
$linked[] = (int) $number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($linked);
|
||||
}
|
||||
}
|
||||
102
tests/Feature/ScanForWorkTest.php
Normal file
102
tests/Feature/ScanForWorkTest.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);
|
||||
|
||||
use Core\Mod\Agentic\Actions\Forge\ScanForWork;
|
||||
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',
|
||||
);
|
||||
|
||||
$this->app->instance(ForgejoService::class, $this->service);
|
||||
});
|
||||
|
||||
it('finds unchecked children needing coding', function () {
|
||||
Http::fake([
|
||||
// List epic issues
|
||||
'forge.example.com/api/v1/repos/core/app/issues?state=open&type=issues&labels=epic' => Http::response([
|
||||
[
|
||||
'number' => 1,
|
||||
'title' => 'Epic: Build the widget',
|
||||
'body' => "## Tasks\n- [ ] #2\n- [x] #3\n- [ ] #4",
|
||||
],
|
||||
]),
|
||||
|
||||
// List PRs — only #4 has a linked PR
|
||||
'forge.example.com/api/v1/repos/core/app/pulls?state=all' => Http::response([
|
||||
[
|
||||
'number' => 10,
|
||||
'title' => 'Fix for issue 4',
|
||||
'body' => 'Closes #4',
|
||||
],
|
||||
]),
|
||||
|
||||
// Child issue #2 (no PR, should be returned)
|
||||
'forge.example.com/api/v1/repos/core/app/issues/2' => Http::response([
|
||||
'number' => 2,
|
||||
'title' => 'Add colour picker',
|
||||
'body' => 'We need a colour picker component.',
|
||||
'assignees' => [['login' => 'virgil']],
|
||||
]),
|
||||
]);
|
||||
|
||||
$items = ScanForWork::run('core', 'app');
|
||||
|
||||
expect($items)->toBeArray()->toHaveCount(1);
|
||||
expect($items[0])->toMatchArray([
|
||||
'epic_number' => 1,
|
||||
'issue_number' => 2,
|
||||
'issue_title' => 'Add colour picker',
|
||||
'issue_body' => 'We need a colour picker component.',
|
||||
'assignee' => 'virgil',
|
||||
'repo_owner' => 'core',
|
||||
'repo_name' => 'app',
|
||||
'needs_coding' => true,
|
||||
'has_pr' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips checked items and items with PRs', function () {
|
||||
Http::fake([
|
||||
'forge.example.com/api/v1/repos/core/app/issues?state=open&type=issues&labels=epic' => Http::response([
|
||||
[
|
||||
'number' => 1,
|
||||
'title' => 'Epic: Build the widget',
|
||||
'body' => "- [x] #2\n- [x] #3\n- [ ] #4",
|
||||
],
|
||||
]),
|
||||
|
||||
'forge.example.com/api/v1/repos/core/app/pulls?state=all' => Http::response([
|
||||
[
|
||||
'number' => 10,
|
||||
'title' => 'Fix for issue 4',
|
||||
'body' => 'Resolves #4',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$items = ScanForWork::run('core', 'app');
|
||||
|
||||
expect($items)->toBeArray()->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('returns empty for repos with no epics', function () {
|
||||
Http::fake([
|
||||
'forge.example.com/api/v1/repos/core/app/issues?state=open&type=issues&labels=epic' => Http::response([]),
|
||||
]);
|
||||
|
||||
$items = ScanForWork::run('core', 'app');
|
||||
|
||||
expect($items)->toBeArray()->toHaveCount(0);
|
||||
});
|
||||
Reference in a new issue