diff --git a/php/Pipeline/EpicChild.php b/php/Pipeline/EpicChild.php new file mode 100644 index 0000000..836e87a --- /dev/null +++ b/php/Pipeline/EpicChild.php @@ -0,0 +1,30 @@ + + */ + public function toArray(): array + { + return [ + 'issue_id' => $this->issueId, + 'state' => $this->state, + 'checked_bool' => $this->checkedBool, + 'linked_pr_number_or_null' => $this->linkedPrNumberOrNull, + ]; + } +} diff --git a/php/Pipeline/EpicMeta.php b/php/Pipeline/EpicMeta.php new file mode 100644 index 0000000..a9bd089 --- /dev/null +++ b/php/Pipeline/EpicMeta.php @@ -0,0 +1,32 @@ + $children + */ + public function __construct( + public string $state, + public array $children, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'state' => $this->state, + 'children' => array_map( + static fn (EpicChild $child): array => $child->toArray(), + $this->children, + ), + ]; + } +} diff --git a/php/Pipeline/ForgejoMetaReader.php b/php/Pipeline/ForgejoMetaReader.php new file mode 100644 index 0000000..d1d457c --- /dev/null +++ b/php/Pipeline/ForgejoMetaReader.php @@ -0,0 +1,599 @@ +resolveRepo(); + + $pr = $this->forgejo->getPullRequest($owner, $repo, $prNumber); + $headSha = $this->stringOrNull($pr['head']['sha'] ?? null); + $status = $headSha === null ? [] : $this->forgejo->getCombinedStatus($owner, $repo, $headSha); + + $reviewThreadsTotal = $this->extractReviewThreadsTotal($pr); + + return new PRMeta( + state: $this->extractPRState($pr), + mergeability: $this->extractMergeability($pr), + headSha: $headSha, + headDate: $this->extractHeadDate($pr, $status), + baseBranch: $this->stringOrNull($pr['base']['ref'] ?? null), + headBranch: $this->stringOrNull($pr['head']['ref'] ?? null), + checkStatuses: $this->extractCheckStatuses($status), + reviewThreadsTotal: $reviewThreadsTotal, + reviewThreadsResolved: $this->extractResolvedThreadCount($pr, $reviewThreadsTotal), + hasEyesReaction: $this->hasEyesReaction($pr), + ); + } + + public function getEpicMeta(int $issueNumber): EpicMeta + { + [$owner, $repo] = $this->resolveRepo(); + + $epic = $this->forgejo->getIssue($owner, $repo, $issueNumber); + + return new EpicMeta( + state: $this->extractIssueLifecycle($epic), + children: $this->extractEpicChildren($owner, $repo, $epic), + ); + } + + public function getIssueState(int $issueNumber): IssueState + { + [$owner, $repo] = $this->resolveRepo(); + + $issue = $this->forgejo->getIssue($owner, $repo, $issueNumber); + + return new IssueState( + state: $this->extractIssueLifecycle($issue), + title: (string) ($issue['title'] ?? ''), + labels: $this->extractLabels($issue), + assignee: $this->extractAssignee($issue), + ); + } + + public function getCommentReactions(int $issueNumber, int $commentNumber): Reactions + { + [$owner, $repo] = $this->resolveRepo(); + + // The reactions API is comment-scoped; the issue number remains on the + // contract for pipeline symmetry and future validation. + unset($issueNumber); + + // ForgejoService does not currently expose reactions, so reuse its + // configured base URL and token here and return counts only. + $reactions = $this->directGet("/repos/{$owner}/{$repo}/issues/comments/{$commentNumber}/reactions"); + + return $this->aggregateReactions($reactions); + } + + /** + * @return array{0: string, 1: string} + */ + private function resolveRepo(): array + { + if ($this->owner !== null || $this->repo !== null) { + if ($this->owner === null || $this->repo === null) { + throw new RuntimeException('ForgejoMetaReader requires both owner and repo when one is provided.'); + } + + return [$this->owner, $this->repo]; + } + + $configuredRepos = array_values(array_filter((array) config('agentic.scan_repos', []))); + + if (count($configuredRepos) !== 1) { + throw new RuntimeException('ForgejoMetaReader requires an explicit owner/repo or exactly one configured agentic.scan_repos entry.'); + } + + $repoSpec = trim((string) $configuredRepos[0]); + $parts = explode('/', $repoSpec, 2); + + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { + throw new RuntimeException("Invalid Forgejo repository spec: {$repoSpec}"); + } + + return [$parts[0], $parts[1]]; + } + + /** + * @param array $pr + */ + private function extractPRState(array $pr): string + { + if (($pr['merged'] ?? false) === true) { + return 'merged'; + } + + $state = strtolower((string) ($pr['state'] ?? '')); + + return $state === '' ? 'unknown' : $state; + } + + /** + * @param array $pr + */ + private function extractMergeability(array $pr): string + { + if (($pr['mergeable'] ?? null) === true) { + return 'mergeable'; + } + + if (($pr['mergeable'] ?? null) === false) { + return 'conflicting'; + } + + return match (strtolower((string) ($pr['mergeable_state'] ?? ''))) { + 'clean', 'mergeable' => 'mergeable', + 'dirty', 'conflicting' => 'conflicting', + default => 'unknown', + }; + } + + /** + * @param array $pr + * @param array $status + */ + private function extractHeadDate(array $pr, array $status): ?string + { + $firstStatus = $status['statuses'][0] ?? []; + + return $this->stringOrNull( + $pr['head']['date'] + ?? $pr['head']['updated_at'] + ?? $pr['head']['repo']['updated_at'] + ?? $pr['head']['repo']['pushed_at'] + ?? $firstStatus['updated_at'] + ?? $firstStatus['created_at'] + ?? $pr['updated_at'] + ?? null, + ); + } + + /** + * @param array $status + * @return array + */ + private function extractCheckStatuses(array $status): array + { + $statuses = $status['statuses'] ?? []; + + if (! is_array($statuses)) { + return []; + } + + $checks = []; + + foreach ($statuses as $entry) { + if (! is_array($entry)) { + continue; + } + + $rawState = strtolower((string) ($entry['status'] ?? $entry['state'] ?? $entry['conclusion'] ?? '')); + $name = (string) ($entry['context'] ?? $entry['name'] ?? ''); + + $checks[] = [ + 'name' => $name, + 'conclusion' => $this->mapCheckConclusion($rawState), + 'status' => $this->mapCheckStatus($rawState), + ]; + } + + return $checks; + } + + private function mapCheckConclusion(string $rawState): ?string + { + return match ($rawState) { + 'success', 'failure', 'error' => $rawState === 'error' ? 'failure' : $rawState, + default => null, + }; + } + + private function mapCheckStatus(string $rawState): ?string + { + return match ($rawState) { + 'success', 'failure', 'error' => 'completed', + 'pending', 'queued' => 'queued', + 'running', 'in_progress' => 'in_progress', + default => $rawState === '' ? null : $rawState, + }; + } + + /** + * @param array $pr + */ + private function extractReviewThreadsTotal(array $pr): int + { + foreach ([ + $pr['review_threads_total'] ?? null, + $pr['review_comments'] ?? null, + $pr['comments'] ?? null, + ] as $candidate) { + $value = $this->intOrNull($candidate); + + if ($value !== null) { + return $value; + } + } + + return 0; + } + + /** + * @param array $pr + */ + private function extractResolvedThreadCount(array $pr, int $reviewThreadsTotal): int + { + $resolved = $this->intOrNull($pr['review_threads_resolved'] ?? $pr['resolved_review_comments'] ?? null); + + if ($resolved !== null) { + return $resolved; + } + + $unresolved = $this->intOrNull($pr['review_threads_unresolved'] ?? $pr['unresolved_review_comments'] ?? null); + + if ($unresolved !== null) { + return max(0, $reviewThreadsTotal - $unresolved); + } + + return 0; + } + + /** + * @param array $pr + */ + private function hasEyesReaction(array $pr): bool + { + return ($this->intOrNull($pr['reactions']['eyes'] ?? null) ?? 0) > 0; + } + + /** + * @param array $issue + */ + private function extractIssueLifecycle(array $issue): string + { + $state = strtolower((string) ($issue['state'] ?? '')); + + return $state === '' ? 'unknown' : $state; + } + + /** + * @param array $issue + * @return array + */ + private function extractLabels(array $issue): array + { + $labels = $issue['labels'] ?? []; + + if (! is_array($labels)) { + return []; + } + + $names = []; + + foreach ($labels as $label) { + if (! is_array($label)) { + continue; + } + + $name = trim((string) ($label['name'] ?? '')); + + if ($name !== '') { + $names[] = $name; + } + } + + return $names; + } + + /** + * @param array $issue + */ + private function extractAssignee(array $issue): ?string + { + return $this->stringOrNull( + $issue['assignee']['login'] + ?? $issue['assignees'][0]['login'] + ?? null, + ); + } + + /** + * @param array $epic + * @return array + */ + private function extractEpicChildren(string $owner, string $repo, array $epic): array + { + $rawChildren = $epic['subtasks'] ?? $epic['sub_issues'] ?? null; + + if (! is_array($rawChildren)) { + // Native Forgejo issue payloads do not consistently expose + // tasklist-style children structurally, so body parsing remains out + // of scope here by design. + return []; + } + + $needsStateLookup = false; + + foreach ($rawChildren as $rawChild) { + if (! is_array($rawChild)) { + continue; + } + + if (! isset($rawChild['state'])) { + $needsStateLookup = true; + break; + } + } + + $issueLookup = $needsStateLookup ? $this->buildIssueLookup($owner, $repo) : []; + $children = []; + + foreach ($rawChildren as $rawChild) { + if (! is_array($rawChild)) { + continue; + } + + $issueId = $this->extractIssueId($rawChild); + + if ($issueId === null) { + continue; + } + + $lookup = $issueLookup[$issueId] ?? []; + + $children[] = new EpicChild( + issueId: $issueId, + state: $this->extractChildState($rawChild, $lookup), + checkedBool: $this->extractCheckedFlag($rawChild), + linkedPrNumberOrNull: $this->extractLinkedPRNumber($rawChild, $lookup), + ); + } + + return $children; + } + + /** + * @return array> + */ + private function buildIssueLookup(string $owner, string $repo): array + { + $lookup = []; + + foreach ($this->forgejo->listIssues($owner, $repo, 'all') as $issue) { + if (! is_array($issue)) { + continue; + } + + $issueId = $this->intOrNull($issue['number'] ?? null); + + if ($issueId === null) { + continue; + } + + $lookup[$issueId] = $issue; + } + + return $lookup; + } + + /** + * @param array $child + */ + private function extractIssueId(array $child): ?int + { + return $this->intOrNull( + $child['issue_id'] + ?? $child['number'] + ?? $child['issue']['number'] + ?? $child['id'] + ?? null, + ); + } + + /** + * @param array $child + * @param array $lookup + */ + private function extractChildState(array $child, array $lookup): string + { + $state = strtolower((string) ($child['state'] ?? $lookup['state'] ?? '')); + + return $state === '' ? 'unknown' : $state; + } + + /** + * @param array $child + */ + private function extractCheckedFlag(array $child): bool + { + foreach ([ + $child['checked'] ?? null, + $child['checked_bool'] ?? null, + $child['is_checked'] ?? null, + $child['completed'] ?? null, + $child['done'] ?? null, + ] as $candidate) { + $bool = $this->boolOrNull($candidate); + + if ($bool !== null) { + return $bool; + } + } + + return false; + } + + /** + * @param array $child + * @param array $lookup + */ + private function extractLinkedPRNumber(array $child, array $lookup): ?int + { + foreach ([ + $child['linked_pr_number_or_null'] ?? null, + $child['linked_pr_number'] ?? null, + $child['linked_pull_request_number'] ?? null, + $child['pull_request']['number'] ?? null, + $child['linked_pull_request']['number'] ?? null, + $lookup['pull_request']['number'] ?? null, + $lookup['linked_pull_request']['number'] ?? null, + ] as $candidate) { + $value = $this->intOrNull($candidate); + + if ($value !== null) { + return $value; + } + } + + return null; + } + + /** + * @param array $reactions + */ + private function aggregateReactions(array $reactions): Reactions + { + $counts = [ + '+1' => 0, + '-1' => 0, + 'laugh' => 0, + 'hooray' => 0, + 'confused' => 0, + 'heart' => 0, + 'rocket' => 0, + 'eyes' => 0, + ]; + + foreach ($reactions as $reaction) { + if (! is_array($reaction)) { + continue; + } + + $content = strtolower((string) ($reaction['content'] ?? '')); + + if (array_key_exists($content, $counts)) { + $counts[$content]++; + } + } + + return new Reactions( + plusOne: $counts['+1'], + minusOne: $counts['-1'], + laugh: $counts['laugh'], + hooray: $counts['hooray'], + confused: $counts['confused'], + heart: $counts['heart'], + rocket: $counts['rocket'], + eyes: $counts['eyes'], + ); + } + + /** + * @return array + */ + private function directGet(string $path): array + { + $response = Http::withToken($this->forgejoToken()) + ->acceptJson() + ->timeout(15) + ->get($this->forgejoBaseUrl().'/api/v1'.$path); + + if (! $response->successful()) { + throw new RuntimeException("Forgejo API GET {$path} failed: {$response->status()}"); + } + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + + private function forgejoBaseUrl(): string + { + return rtrim((string) $this->readForgejoProperty('baseUrl'), '/'); + } + + private function forgejoToken(): string + { + return (string) $this->readForgejoProperty('token'); + } + + private function readForgejoProperty(string $property): mixed + { + $reflection = new ReflectionClass($this->forgejo); + + do { + if ($reflection->hasProperty($property)) { + $prop = $reflection->getProperty($property); + $prop->setAccessible(true); + + return $prop->getValue($this->forgejo); + } + } while ($reflection = $reflection->getParentClass()); + + throw new RuntimeException("Unable to read ForgejoService::\${$property}"); + } + + private function intOrNull(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value; + } + + return null; + } + + private function boolOrNull(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + return match (strtolower($value)) { + '1', 'true', 'yes', 'x' => true, + '0', 'false', 'no', '' => false, + default => null, + }; + } + + if (is_int($value)) { + return $value !== 0; + } + + return null; + } + + private function stringOrNull(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $string = trim((string) $value); + + return $string === '' ? null : $string; + } +} diff --git a/php/Pipeline/IssueState.php b/php/Pipeline/IssueState.php new file mode 100644 index 0000000..901b3fa --- /dev/null +++ b/php/Pipeline/IssueState.php @@ -0,0 +1,33 @@ + $labels + */ + public function __construct( + public string $state, + public string $title, + public array $labels, + public ?string $assignee, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'state' => $this->state, + 'title' => $this->title, + 'labels' => $this->labels, + 'assignee' => $this->assignee, + ]; + } +} diff --git a/php/Pipeline/MetaReader.php b/php/Pipeline/MetaReader.php new file mode 100644 index 0000000..c0040a6 --- /dev/null +++ b/php/Pipeline/MetaReader.php @@ -0,0 +1,18 @@ + $checkStatuses + */ + public function __construct( + public string $state, + public string $mergeability, + public ?string $headSha, + public ?string $headDate, + public ?string $baseBranch, + public ?string $headBranch, + public array $checkStatuses, + public int $reviewThreadsTotal, + public int $reviewThreadsResolved, + public bool $hasEyesReaction, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'state' => $this->state, + 'mergeability' => $this->mergeability, + 'head_sha' => $this->headSha, + 'head_date' => $this->headDate, + 'base_branch' => $this->baseBranch, + 'head_branch' => $this->headBranch, + 'check_statuses' => $this->checkStatuses, + 'review_threads_total' => $this->reviewThreadsTotal, + 'review_threads_resolved' => $this->reviewThreadsResolved, + 'has_eyes_reaction' => $this->hasEyesReaction, + ]; + } +} diff --git a/php/Pipeline/Reactions.php b/php/Pipeline/Reactions.php new file mode 100644 index 0000000..dfe73d3 --- /dev/null +++ b/php/Pipeline/Reactions.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + return [ + '+1' => $this->plusOne, + '-1' => $this->minusOne, + 'laugh' => $this->laugh, + 'hooray' => $this->hooray, + 'confused' => $this->confused, + 'heart' => $this->heart, + 'rocket' => $this->rocket, + 'eyes' => $this->eyes, + ]; + } +} diff --git a/php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php b/php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php new file mode 100644 index 0000000..06ed068 --- /dev/null +++ b/php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php @@ -0,0 +1,237 @@ +makePartial(); + $service->shouldReceive('getPullRequest') + ->once() + ->with('core', 'app', 89) + ->andReturn([ + 'state' => 'open', + 'mergeable' => true, + 'body' => 'Ignore this PR description', + 'description' => 'Ignore this too', + 'review_text' => 'Untrusted review content', + 'head' => [ + 'sha' => 'abc123', + 'ref' => 'agent/mantis-89', + 'date' => '2026-04-23T10:15:00Z', + ], + 'base' => [ + 'ref' => 'dev', + ], + 'review_comments' => 4, + 'unresolved_review_comments' => 1, + 'reactions' => [ + 'eyes' => 2, + 'heart' => 1, + ], + ]); + $service->shouldReceive('getCombinedStatus') + ->once() + ->with('core', 'app', 'abc123') + ->andReturn([ + 'state' => 'success', + 'statuses' => [ + [ + 'context' => 'qa', + 'status' => 'success', + 'description' => 'Body-like status description', + 'comment_body' => 'Never forward this', + ], + [ + 'context' => 'build', + 'status' => 'pending', + 'review_text' => 'Still untrusted', + ], + ], + ]); + + $reader = new ForgejoMetaReader($service, 'core', 'app'); + + $dto = $reader->getPRMeta(89); + $array = $dto->toArray(); + + expect($dto)->toBeInstanceOf(PRMeta::class) + ->and($array)->toMatchArray([ + 'state' => 'open', + 'mergeability' => 'mergeable', + 'head_sha' => 'abc123', + 'head_date' => '2026-04-23T10:15:00Z', + 'base_branch' => 'dev', + 'head_branch' => 'agent/mantis-89', + 'review_threads_total' => 4, + 'review_threads_resolved' => 3, + 'has_eyes_reaction' => true, + ]); + + expect($array)->not->toHaveKey('body'); + expect($array)->not->toHaveKey('description'); + expect($array)->not->toHaveKey('review_text'); + expect($array)->not->toHaveKey('comment_body'); + + expect($array['check_statuses'])->toHaveCount(2); + expect($array['check_statuses'][0])->toMatchArray([ + 'name' => 'qa', + 'conclusion' => 'success', + 'status' => 'completed', + ]); + expect($array['check_statuses'][0])->not->toHaveKey('body'); + expect($array['check_statuses'][0])->not->toHaveKey('description'); + expect($array['check_statuses'][0])->not->toHaveKey('review_text'); + expect($array['check_statuses'][0])->not->toHaveKey('comment_body'); +}); + +it('projects epic metadata without child body-like fields', function () { + $service = Mockery::mock(ForgejoService::class, ['https://forge.example.com', 'test-token'])->makePartial(); + $service->shouldReceive('getIssue') + ->once() + ->with('core', 'app', 12) + ->andReturn([ + 'state' => 'open', + 'body' => "## Tasks\n- [ ] #101\n- [x] #102", + 'sub_issues' => [ + [ + 'number' => 101, + 'state' => 'open', + 'checked' => false, + 'linked_pr_number' => 501, + 'description' => 'Never expose this', + ], + [ + 'number' => 102, + 'state' => 'closed', + 'checked' => true, + 'comment_body' => 'Nor this', + ], + ], + ]); + + $reader = new ForgejoMetaReader($service, 'core', 'app'); + + $dto = $reader->getEpicMeta(12); + $array = $dto->toArray(); + + expect($dto)->toBeInstanceOf(EpicMeta::class) + ->and($array)->toMatchArray([ + 'state' => 'open', + 'children' => [ + [ + 'issue_id' => 101, + 'state' => 'open', + 'checked_bool' => false, + 'linked_pr_number_or_null' => 501, + ], + [ + 'issue_id' => 102, + 'state' => 'closed', + 'checked_bool' => true, + 'linked_pr_number_or_null' => null, + ], + ], + ]); + + expect($array)->not->toHaveKey('body'); + expect($array)->not->toHaveKey('description'); + expect($array)->not->toHaveKey('review_text'); + expect($array)->not->toHaveKey('comment_body'); + + expect($array['children'][0])->not->toHaveKey('body'); + expect($array['children'][0])->not->toHaveKey('description'); + expect($array['children'][0])->not->toHaveKey('review_text'); + expect($array['children'][0])->not->toHaveKey('comment_body'); +}); + +it('projects issue state without body-like fields', function () { + $service = Mockery::mock(ForgejoService::class, ['https://forge.example.com', 'test-token'])->makePartial(); + $service->shouldReceive('getIssue') + ->once() + ->with('core', 'app', 101) + ->andReturn([ + 'state' => 'open', + 'title' => 'Add MetaReader contract', + 'body' => 'Do not forward me', + 'description' => 'Do not forward me either', + 'labels' => [ + ['name' => 'pipeline'], + ['name' => 'agent'], + ], + 'assignee' => [ + 'login' => 'virgil', + 'description' => 'Still not pipeline-safe', + ], + ]); + + $reader = new ForgejoMetaReader($service, 'core', 'app'); + + $dto = $reader->getIssueState(101); + $array = $dto->toArray(); + + expect($dto)->toBeInstanceOf(IssueState::class) + ->and($array)->toMatchArray([ + 'state' => 'open', + 'title' => 'Add MetaReader contract', + 'labels' => ['pipeline', 'agent'], + 'assignee' => 'virgil', + ]); + + expect($array)->not->toHaveKey('body'); + expect($array)->not->toHaveKey('description'); + expect($array)->not->toHaveKey('review_text'); + expect($array)->not->toHaveKey('comment_body'); +}); + +it('projects comment reactions as counts only', function () { + Http::fake([ + 'forge.example.com/api/v1/repos/core/app/issues/comments/700/reactions' => Http::response([ + ['content' => '+1', 'comment_body' => 'ignore'], + ['content' => '+1'], + ['content' => 'eyes', 'body' => 'ignore'], + ['content' => 'rocket', 'review_text' => 'ignore'], + ['content' => 'heart', 'description' => 'ignore'], + ]), + ]); + + $service = Mockery::mock(ForgejoService::class, ['https://forge.example.com', 'test-token'])->makePartial(); + $reader = new ForgejoMetaReader($service, 'core', 'app'); + + $dto = $reader->getCommentReactions(101, 700); + $array = $dto->toArray(); + + expect($dto)->toBeInstanceOf(Reactions::class) + ->and($array)->toMatchArray([ + '+1' => 2, + '-1' => 0, + 'laugh' => 0, + 'hooray' => 0, + 'confused' => 0, + 'heart' => 1, + 'rocket' => 1, + 'eyes' => 1, + ]); + + expect($array)->not->toHaveKey('body'); + expect($array)->not->toHaveKey('description'); + expect($array)->not->toHaveKey('review_text'); + expect($array)->not->toHaveKey('comment_body'); + + Http::assertSent(function ($request) { + return $request->hasHeader('Authorization', 'Bearer test-token') + && $request->url() === 'https://forge.example.com/api/v1/repos/core/app/issues/comments/700/reactions'; + }); +});