agent/php/tests/Unit/Pipeline/ForgejoMetaReaderTest.php
Snider e83c3d811d 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>
2026-04-23 18:09:54 +01:00

237 lines
8.1 KiB
PHP

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