diff --git a/Actions/Forge/ScanForWork.php b/Actions/Forge/ScanForWork.php new file mode 100644 index 0000000..d622bfb --- /dev/null +++ b/Actions/Forge/ScanForWork.php @@ -0,0 +1,145 @@ + + */ + 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 + */ + 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> $pullRequests + * @return array + */ + 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); + } +} diff --git a/tests/Feature/ScanForWorkTest.php b/tests/Feature/ScanForWorkTest.php new file mode 100644 index 0000000..2d567d8 --- /dev/null +++ b/tests/Feature/ScanForWorkTest.php @@ -0,0 +1,102 @@ +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); +});