feat(pipeline): add MetaReader contract + Forgejo-backed implementation
Introduce a pipeline metadata surface that enforces "no body content ever reaches pipeline decisions". MetaReader is an interface with four methods (getPRMeta, getEpicMeta, getIssueState, getCommentReactions), each returning a readonly DTO carrying only structural fields — state, mergeability, SHAs, branches, reaction counts, child linkage. ForgejoMetaReader projects raw Forgejo API payloads into these DTOs and drops body/description/review text before the caller can see it. Unit test mocks rich Forgejo payloads containing body, description, review_text, and comment_body, then asserts the DTO toArray output never exposes those keys — the regression fence for the RFC rule. Downstream callers (ScanForWork, ManagePullRequest) still use the raw ForgejoService today; that refactor lands under Mantis #90. Closes tasks.lthn.sh/view.php?id=89 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
d6ddb9f2e6
commit
e83c3d811d
8 changed files with 1032 additions and 0 deletions
30
php/Pipeline/EpicChild.php
Normal file
30
php/Pipeline/EpicChild.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
final readonly class EpicChild
|
||||
{
|
||||
public function __construct(
|
||||
public int $issueId,
|
||||
public string $state,
|
||||
public bool $checkedBool,
|
||||
public ?int $linkedPrNumberOrNull,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'issue_id' => $this->issueId,
|
||||
'state' => $this->state,
|
||||
'checked_bool' => $this->checkedBool,
|
||||
'linked_pr_number_or_null' => $this->linkedPrNumberOrNull,
|
||||
];
|
||||
}
|
||||
}
|
||||
32
php/Pipeline/EpicMeta.php
Normal file
32
php/Pipeline/EpicMeta.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
final readonly class EpicMeta
|
||||
{
|
||||
/**
|
||||
* @param array<int, EpicChild> $children
|
||||
*/
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public array $children,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'children' => array_map(
|
||||
static fn (EpicChild $child): array => $child->toArray(),
|
||||
$this->children,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
599
php/Pipeline/ForgejoMetaReader.php
Normal file
599
php/Pipeline/ForgejoMetaReader.php
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
use Core\Mod\Agentic\Services\ForgejoService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
final class ForgejoMetaReader implements MetaReader
|
||||
{
|
||||
public function __construct(
|
||||
private ForgejoService $forgejo,
|
||||
private ?string $owner = null,
|
||||
private ?string $repo = null,
|
||||
) {}
|
||||
|
||||
public function getPRMeta(int $prNumber): PRMeta
|
||||
{
|
||||
[$owner, $repo] = $this->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<string, mixed> $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<string, mixed> $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<string, mixed> $pr
|
||||
* @param array<string, mixed> $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<string, mixed> $status
|
||||
* @return array<int, array{name: string, conclusion: string|null, status: string|null}>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $pr
|
||||
*/
|
||||
private function hasEyesReaction(array $pr): bool
|
||||
{
|
||||
return ($this->intOrNull($pr['reactions']['eyes'] ?? null) ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $issue
|
||||
*/
|
||||
private function extractIssueLifecycle(array $issue): string
|
||||
{
|
||||
$state = strtolower((string) ($issue['state'] ?? ''));
|
||||
|
||||
return $state === '' ? 'unknown' : $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $issue
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<string, mixed> $issue
|
||||
*/
|
||||
private function extractAssignee(array $issue): ?string
|
||||
{
|
||||
return $this->stringOrNull(
|
||||
$issue['assignee']['login']
|
||||
?? $issue['assignees'][0]['login']
|
||||
?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $epic
|
||||
* @return array<int, EpicChild>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $child
|
||||
*/
|
||||
private function extractIssueId(array $child): ?int
|
||||
{
|
||||
return $this->intOrNull(
|
||||
$child['issue_id']
|
||||
?? $child['number']
|
||||
?? $child['issue']['number']
|
||||
?? $child['id']
|
||||
?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $child
|
||||
* @param array<string, mixed> $lookup
|
||||
*/
|
||||
private function extractChildState(array $child, array $lookup): string
|
||||
{
|
||||
$state = strtolower((string) ($child['state'] ?? $lookup['state'] ?? ''));
|
||||
|
||||
return $state === '' ? 'unknown' : $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $child
|
||||
* @param array<string, mixed> $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<int, mixed> $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<int, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
33
php/Pipeline/IssueState.php
Normal file
33
php/Pipeline/IssueState.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
final readonly class IssueState
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
*/
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public string $title,
|
||||
public array $labels,
|
||||
public ?string $assignee,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'title' => $this->title,
|
||||
'labels' => $this->labels,
|
||||
'assignee' => $this->assignee,
|
||||
];
|
||||
}
|
||||
}
|
||||
18
php/Pipeline/MetaReader.php
Normal file
18
php/Pipeline/MetaReader.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
interface MetaReader
|
||||
{
|
||||
public function getPRMeta(int $prNumber): PRMeta;
|
||||
|
||||
public function getEpicMeta(int $issueNumber): EpicMeta;
|
||||
|
||||
public function getIssueState(int $issueNumber): IssueState;
|
||||
|
||||
public function getCommentReactions(int $issueNumber, int $commentNumber): Reactions;
|
||||
}
|
||||
45
php/Pipeline/PRMeta.php
Normal file
45
php/Pipeline/PRMeta.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
final readonly class PRMeta
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{name: string, conclusion: string|null, status: string|null}> $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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
38
php/Pipeline/Reactions.php
Normal file
38
php/Pipeline/Reactions.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Pipeline;
|
||||
|
||||
final readonly class Reactions
|
||||
{
|
||||
public function __construct(
|
||||
public int $plusOne = 0,
|
||||
public int $minusOne = 0,
|
||||
public int $laugh = 0,
|
||||
public int $hooray = 0,
|
||||
public int $confused = 0,
|
||||
public int $heart = 0,
|
||||
public int $rocket = 0,
|
||||
public int $eyes = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
237
php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php
Normal file
237
php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Pipeline\EpicMeta;
|
||||
use Core\Mod\Agentic\Pipeline\ForgejoMetaReader;
|
||||
use Core\Mod\Agentic\Pipeline\IssueState;
|
||||
use Core\Mod\Agentic\Pipeline\PRMeta;
|
||||
use Core\Mod\Agentic\Pipeline\Reactions;
|
||||
use Core\Mod\Agentic\Services\ForgejoService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('projects pull request metadata without body-like fields', function () {
|
||||
$service = Mockery::mock(ForgejoService::class, ['https://forge.example.com', 'test-token'])->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';
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue