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:
Snider 2026-04-23 18:09:54 +01:00
parent d6ddb9f2e6
commit e83c3d811d
8 changed files with 1032 additions and 0 deletions

View 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
View 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,
),
];
}
}

View 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;
}
}

View 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,
];
}
}

View 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
View 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,
];
}
}

View 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,
];
}
}

View 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';
});
});