*/ 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); } }