Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions app/Commands/AnalyzeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use GuzzleHttp\Exception\GuzzleException;
use LaravelZero\Framework\Commands\Command;

class AnalyzeCommand extends Command
final class AnalyzeCommand extends Command
{
protected $signature = 'analyze
{--failures= : JSON file with failures to analyze}
Expand All @@ -17,6 +17,22 @@ class AnalyzeCommand extends Command

protected $description = 'Send failures to Prefrontal Cortex for AI analysis';

private ?Client $httpClient = null;

private ?string $repoOverride = null;

private ?string $shaOverride = null;

/** @internal For testing only */
public function withMocks(?Client $httpClient = null, ?string $repo = null, ?string $sha = null): self
{
$this->httpClient = $httpClient;
$this->repoOverride = $repo;
$this->shaOverride = $sha;

return $this;
}

public function handle(): int
{
$apiUrl = $this->option('api-url') ?? getenv('PREFRONTAL_API_URL') ?: 'https://prefrontal.jordanpartridge.us';
Expand Down Expand Up @@ -52,7 +68,7 @@ public function handle(): int
$this->info('🧠 Sending failures to Prefrontal Cortex for analysis...');

try {
$client = new Client([
$client = $this->httpClient ?? new Client([
'base_uri' => $apiUrl,
'timeout' => 60,
]);
Expand Down Expand Up @@ -93,6 +109,10 @@ public function handle(): int

protected function detectRepo(): string
{
if ($this->repoOverride !== null) {
return $this->repoOverride;
}

$remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? '');
if (preg_match('#github\.com[:/](.+?)(?:\.git)?$#', $remote, $matches)) {
return $matches[1];
Expand All @@ -103,6 +123,10 @@ protected function detectRepo(): string

protected function detectSha(): string
{
if ($this->shaOverride !== null) {
return $this->shaOverride;
}

return trim(shell_exec('git rev-parse HEAD 2>/dev/null') ?? '');
}

Expand Down
255 changes: 255 additions & 0 deletions tests/Unit/Commands/AnalyzeCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
<?php

declare(strict_types=1);

use App\Commands\AnalyzeCommand;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;

beforeEach(function () {
$this->createCommand = function (Client $httpClient, ?string $repo = null, ?string $sha = null) {
$command = new AnalyzeCommand;
$command->withMocks($httpClient, $repo ?? 'owner/repo', $sha ?? 'abc123');
app()->singleton(AnalyzeCommand::class, fn () => $command);
};

$this->failuresFile = tempnam(sys_get_temp_dir(), 'gate_test_failures_');
file_put_contents($this->failuresFile, json_encode([
['test' => 'SomeTest::it_works', 'message' => 'Failed assertion'],
]));
});

afterEach(function () {
if (file_exists($this->failuresFile)) {
@unlink($this->failuresFile);
}
});

describe('AnalyzeCommand', function () {
describe('handle', function () {
it('returns failure when api token is missing', function () {
putenv('PREFRONTAL_API_TOKEN');

$mock = new MockHandler([]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--failures' => $this->failuresFile,
])->assertFailed();
});

it('returns failure when failures file is not provided', function () {
$mock = new MockHandler([]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
])->assertFailed();
});

it('returns failure when failures file does not exist', function () {
$mock = new MockHandler([]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => '/nonexistent/path/failures.json',
])->assertFailed();
});

it('returns failure when failures file contains invalid json', function () {
file_put_contents($this->failuresFile, 'not valid json{{{');

$mock = new MockHandler([]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertFailed();
});

it('returns success when api responds with fixes', function () {
$apiResponse = [
'fixes' => [
['type' => 'bug', 'file' => 'src/Foo.php', 'suggestion' => 'Fix the null check'],
['type' => 'style', 'file' => 'src/Bar.php'],
],
'minimal_report' => 'Found 2 issues to fix.',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});

it('returns success when api responds with empty fixes', function () {
$apiResponse = [
'fixes' => [],
'minimal_report' => 'No actionable fixes found.',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});

it('returns success when api responds without fixes key', function () {
$apiResponse = [
'minimal_report' => 'Analysis complete.',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});

it('returns failure when api responds with null body', function () {
$mock = new MockHandler([
new Response(200, [], 'null'),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertFailed();
});

it('returns failure when guzzle throws exception', function () {
$mock = new MockHandler([
new ConnectException('Connection refused', new Request('POST', '/api/gate/analyze')),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertFailed();
});

it('uses api-url option when provided', function () {
$apiResponse = [
'fixes' => [],
'minimal_report' => 'OK',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--api-url' => 'https://custom-api.example.com',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});

it('uses api token from environment variable', function () {
putenv('PREFRONTAL_API_TOKEN=env-test-token');

$apiResponse = [
'fixes' => [],
'minimal_report' => 'OK',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--failures' => $this->failuresFile,
])->assertSuccessful();

putenv('PREFRONTAL_API_TOKEN');
});

it('outputs fix suggestions when present', function () {
$apiResponse = [
'fixes' => [
['type' => 'bug', 'file' => 'src/Foo.php', 'suggestion' => 'Add null check on line 42'],
],
'minimal_report' => 'Found 1 issue.',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});

it('handles fixes with missing type and file gracefully', function () {
$apiResponse = [
'fixes' => [
[],
],
'minimal_report' => 'Done.',
];

$mock = new MockHandler([
new Response(200, [], json_encode($apiResponse)),
]);
$httpClient = new Client(['handler' => HandlerStack::create($mock)]);

($this->createCommand)($httpClient);

$this->artisan('analyze', [
'--api-token' => 'test-token',
'--failures' => $this->failuresFile,
])->assertSuccessful();
});
});
});
Loading