From 9ab95d2d8e5aae22551b320b592c54019b824afb Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 12 Feb 2026 18:27:30 +0000 Subject: [PATCH] refactor: remove CodeRabbit extraction command and all associated code Closes #125 --- README.md | 1 - ROADMAP.md | 3 - app/Commands/CoderabbitExtractCommand.php | 171 --------- app/Services/CodeRabbitService.php | 268 ------------- .../Commands/CoderabbitExtractCommandTest.php | 281 -------------- tests/Unit/Services/CodeRabbitServiceTest.php | 352 ------------------ 6 files changed, 1076 deletions(-) delete mode 100644 app/Commands/CoderabbitExtractCommand.php delete mode 100644 app/Services/CodeRabbitService.php delete mode 100644 tests/Feature/Commands/CoderabbitExtractCommandTest.php delete mode 100644 tests/Unit/Services/CodeRabbitServiceTest.php diff --git a/README.md b/README.md index fb4fbcf..cf8e0ee 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,6 @@ All commands support `--project=` to target a specific project namespace a | `stage` | Stage entries in daily log before permanent storage | | `promote` | Promote staged entries to permanent knowledge | | `enhance:worker` | Process the background Ollama enhancement queue | -| `coderabbit:extract` | Extract CodeRabbit review findings from a GitHub PR | ### Infrastructure diff --git a/ROADMAP.md b/ROADMAP.md index 0ef0786..c13494e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,9 +32,6 @@ Index and search codebases semantically via `index-code` and `search-code`. ### Context Command Semantic session context loading for AI tools — auto-injects relevant knowledge into Claude Code sessions. -### CodeRabbit Review Extraction -Extract CodeRabbit review findings from GitHub PRs and store as knowledge entries via `coderabbit:extract`. - ### Background Ollama Auto-Tagging Async auto-tagging via OllamaService with file-based enhancement queue. `know add` stays fast (<100ms), enhancement happens in background via `enhance:worker`. diff --git a/app/Commands/CoderabbitExtractCommand.php b/app/Commands/CoderabbitExtractCommand.php deleted file mode 100644 index bd49dc0..0000000 --- a/app/Commands/CoderabbitExtractCommand.php +++ /dev/null @@ -1,171 +0,0 @@ -option('pr')) ? $this->option('pr') : null; - /** @var string $minSeverity */ - $minSeverity = is_string($this->option('min-severity')) ? $this->option('min-severity') : 'low'; - /** @var bool $dryRun */ - $dryRun = (bool) $this->option('dry-run'); - - if ($prOption === null || $prOption === '') { - error('The --pr option is required.'); - - return self::FAILURE; - } - - if (! is_numeric($prOption) || (int) $prOption < 1) { - error('The --pr option must be a positive integer.'); - - return self::FAILURE; - } - - $prNumber = (int) $prOption; - - if (! in_array($minSeverity, self::VALID_SEVERITIES, true)) { - error('Invalid --min-severity. Valid: '.implode(', ', self::VALID_SEVERITIES)); - - return self::FAILURE; - } - - // Fetch CodeRabbit review comments - /** @var array{comments: list, pr_title: string, pr_url: string}|null $reviewData */ - $reviewData = spin( - fn () => $coderabbit->fetchReviewComments($prNumber), - "Fetching CodeRabbit reviews for PR #{$prNumber}..." - ); - - if ($reviewData === null) { - error("Failed to fetch PR #{$prNumber}. Ensure gh CLI is authenticated and the PR exists."); - - return self::FAILURE; - } - - if ($reviewData['comments'] === []) { - warning("No CodeRabbit review comments found on PR #{$prNumber}."); - - return self::SUCCESS; - } - - info('Found '.count($reviewData['comments'])." CodeRabbit comment(s) on PR #{$prNumber}: {$reviewData['pr_title']}"); - - // Parse into structured findings - $findings = $coderabbit->parseFindings($reviewData['comments']); - - if ($findings === []) { - warning('No actionable findings extracted from CodeRabbit reviews.'); - - return self::SUCCESS; - } - - // Filter by minimum severity - $findings = $coderabbit->filterBySeverity($findings, $minSeverity); - - if ($findings === []) { - warning("No findings meet the minimum severity threshold: {$minSeverity}"); - - return self::SUCCESS; - } - - info(count($findings)." finding(s) extracted (min severity: {$minSeverity})"); - - if ($dryRun) { - $this->displayFindings($findings, $prNumber); - info('Dry run complete. No entries added to knowledge base.'); - - return self::SUCCESS; - } - - // Add findings to knowledge base - $added = 0; - $failed = 0; - - foreach ($findings as $index => $finding) { - $semanticId = "knowledge-pr-{$prNumber}-coderabbit-".($index + 1); - - $data = [ - 'id' => $semanticId, - 'title' => "[PR #{$prNumber}] {$finding['title']}", - 'content' => $finding['content'], - 'tags' => array_filter(['coderabbit', 'code-review', "pr-{$prNumber}", $finding['file']]), - 'category' => 'architecture', - 'priority' => $finding['severity'], - 'confidence' => $finding['confidence'], - 'status' => 'draft', - 'source' => $reviewData['pr_url'], - 'ticket' => "PR-{$prNumber}", - 'evidence' => "Extracted from CodeRabbit review on PR #{$prNumber}", - 'last_verified' => now()->toIso8601String(), - ]; - - try { - $success = $qdrant->upsert($data, 'default', false); - if ($success) { - $added++; - } else { - $failed++; - } - } catch (\Throwable) { - $failed++; - } - } - - $this->displayFindings($findings, $prNumber); - - info("{$added} finding(s) added to knowledge base."); - if ($failed > 0) { - warning("{$failed} finding(s) failed to store."); - } - - return self::SUCCESS; - } - - /** - * @param list $findings - */ - private function displayFindings(array $findings, int $prNumber): void - { - $rows = []; - foreach ($findings as $index => $finding) { - $rows[] = [ - "knowledge-pr-{$prNumber}-coderabbit-".($index + 1), - Str::limit($finding['title'], 50), - strtoupper($finding['severity']), - "{$finding['confidence']}%", - $finding['file'] ?? 'N/A', - ]; - } - - table( - ['Semantic ID', 'Title', 'Severity', 'Confidence', 'File'], - $rows - ); - } -} diff --git a/app/Services/CodeRabbitService.php b/app/Services/CodeRabbitService.php deleted file mode 100644 index a3d77e1..0000000 --- a/app/Services/CodeRabbitService.php +++ /dev/null @@ -1,268 +0,0 @@ - ['security', 'vulnerability', 'injection', 'exploit', 'crash', 'data loss', 'corruption', 'race condition'], - 'high' => ['bug', 'error', 'broken', 'fail', 'incorrect', 'wrong', 'memory leak', 'performance issue', 'missing validation'], - 'medium' => ['refactor', 'improvement', 'simplify', 'consider', 'suggest', 'better', 'cleaner', 'readability', 'maintainability'], - 'low' => ['nit', 'style', 'typo', 'naming', 'formatting', 'comment', 'documentation', 'minor'], - ]; - - private const SEVERITY_ORDER = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1]; - - private const CONFIDENCE_MAP = ['critical' => 85, 'high' => 75, 'medium' => 60, 'low' => 45]; - - /** - * @param string|null $workingDirectory Optional working directory for gh CLI commands - */ - public function __construct( - private readonly ?string $workingDirectory = null - ) {} - - /** - * Fetch CodeRabbit review comments from a PR using gh CLI. - * - * @return array{comments: list, pr_title: string, pr_url: string}|null - */ - public function fetchReviewComments(int $prNumber): ?array - { - $prData = $this->fetchPrMetadata($prNumber); - if ($prData === null) { - return null; - } - - $comments = $this->fetchPrComments($prNumber); - if ($comments === null) { - return null; - } - - $coderabbitComments = array_values(array_filter($comments, function (array $comment): bool { - return str_contains(strtolower($comment['author']), 'coderabbit'); - })); - - return [ - 'comments' => $coderabbitComments, - 'pr_title' => $prData['title'], - 'pr_url' => $prData['url'], - ]; - } - - /** - * Parse review comments into structured findings. - * - * @param list $comments - * @return list - */ - public function parseFindings(array $comments): array - { - $findings = []; - - foreach ($comments as $comment) { - $body = $comment['body'] ?? ''; - if (trim($body) === '') { - continue; - } - - $sections = $this->splitIntoFindings($body, $comment['path'] ?? null); - foreach ($sections as $section) { - $findings[] = $section; - } - } - - return $findings; - } - - /** - * Filter findings by minimum severity level. - * - * @param list $findings - * @return list - */ - public function filterBySeverity(array $findings, string $minSeverity): array - { - $minOrder = self::SEVERITY_ORDER[$minSeverity] ?? 1; - - return array_values(array_filter($findings, function (array $finding) use ($minOrder): bool { - $order = self::SEVERITY_ORDER[$finding['severity']] ?? 1; - - return $order >= $minOrder; - })); - } - - /** - * Split a comment body into individual findings. - * - * @return list - */ - private function splitIntoFindings(string $body, ?string $file): array - { - $findings = []; - - // Split on markdown headers (##, ###) or numbered list items that look like distinct findings - $sections = preg_split('/(?=^#{2,3}\s+)/m', $body); - - if ($sections === false || count($sections) <= 1) { - // No headers found - treat as single finding - $severity = $this->detectSeverity($body); - - return [[ - 'title' => $this->extractTitle($body), - 'content' => trim($body), - 'file' => $file, - 'severity' => $severity, - 'confidence' => self::CONFIDENCE_MAP[$severity], - ]]; - } - - foreach ($sections as $section) { - $section = trim($section); - if ($section === '') { - continue; - } - - $severity = $this->detectSeverity($section); - $findings[] = [ - 'title' => $this->extractTitle($section), - 'content' => trim($section), - 'file' => $file, - 'severity' => $severity, - 'confidence' => self::CONFIDENCE_MAP[$severity], - ]; - } - - return $findings; - } - - /** - * Detect severity from content keywords. - */ - private function detectSeverity(string $content): string - { - $lower = strtolower($content); - - foreach (self::SEVERITY_MAP as $severity => $keywords) { - foreach ($keywords as $keyword) { - if (str_contains($lower, $keyword)) { - return $severity; - } - } - } - - return 'medium'; - } - - /** - * Extract a title from the first line or header of a section. - */ - private function extractTitle(string $section): string - { - $lines = explode("\n", trim($section)); - $firstLine = trim($lines[0]); - - // Remove markdown header markers - $title = (string) preg_replace('/^#{1,6}\s+/', '', $firstLine); - - // Truncate long titles - if (mb_strlen($title) > 120) { - $title = mb_substr($title, 0, 117).'...'; - } - - return $title; - } - - /** - * @return array{title: string, url: string}|null - */ - private function fetchPrMetadata(int $prNumber): ?array - { - $process = $this->runGhCommand([ - 'pr', 'view', (string) $prNumber, - '--json', 'title,url', - '--jq', '.', - ]); - - if (! $process->isSuccessful()) { - return null; - } - - /** @var array{title: string, url: string}|null $data */ - $data = json_decode($process->getOutput(), true); - - return is_array($data) ? $data : null; - } - - /** - * @return list|null - */ - private function fetchPrComments(int $prNumber): ?array - { - // Fetch issue comments (top-level PR comments) - $issueProcess = $this->runGhCommand([ - 'api', "repos/{owner}/{repo}/issues/{$prNumber}/comments", - '--jq', '[.[] | {body: .body, path: null, author: .user.login}]', - ]); - - // Fetch review comments (inline code comments) - $reviewProcess = $this->runGhCommand([ - 'api', "repos/{owner}/{repo}/pulls/{$prNumber}/comments", - '--jq', '[.[] | {body: .body, path: .path, author: .user.login}]', - ]); - - $comments = []; - - if ($issueProcess->isSuccessful()) { - /** @var list|null $issueComments */ - $issueComments = json_decode($issueProcess->getOutput(), true); - if (is_array($issueComments)) { - $comments = array_merge($comments, $issueComments); - } - } - - if ($reviewProcess->isSuccessful()) { - /** @var list|null $reviewComments */ - $reviewComments = json_decode($reviewProcess->getOutput(), true); - if (is_array($reviewComments)) { - $comments = array_merge($comments, $reviewComments); - } - } - - if ($comments === [] && ! $issueProcess->isSuccessful() && ! $reviewProcess->isSuccessful()) { - return null; - } - - return $comments; - } - - /** - * @param array $command - * - * @codeCoverageIgnore Process creation — overridden in tests - */ - protected function runGhCommand(array $command): Process - { - $cwd = $this->workingDirectory ?? getcwd(); - - // @codeCoverageIgnoreStart - if ($cwd === false) { - $cwd = null; - } - // @codeCoverageIgnoreEnd - - $process = new Process( - ['gh', ...$command], - is_string($cwd) ? $cwd : null - ); - - $process->setTimeout(30); - $process->run(); - - return $process; - } -} diff --git a/tests/Feature/Commands/CoderabbitExtractCommandTest.php b/tests/Feature/Commands/CoderabbitExtractCommandTest.php deleted file mode 100644 index a57125a..0000000 --- a/tests/Feature/Commands/CoderabbitExtractCommandTest.php +++ /dev/null @@ -1,281 +0,0 @@ -mockQdrant = Mockery::mock(QdrantService::class); - $this->mockCodeRabbit = Mockery::mock(CodeRabbitService::class); - $this->app->instance(QdrantService::class, $this->mockQdrant); - $this->app->instance(CodeRabbitService::class, $this->mockCodeRabbit); -}); - -afterEach(function (): void { - Mockery::close(); -}); - -it('requires --pr option', function (): void { - $this->artisan('coderabbit:extract') - ->assertFailed() - ->expectsOutputToContain('--pr option is required'); -}); - -it('validates --pr must be a positive integer', function (): void { - $this->artisan('coderabbit:extract', ['--pr' => 'abc']) - ->assertFailed() - ->expectsOutputToContain('positive integer'); -}); - -it('validates --pr rejects zero', function (): void { - $this->artisan('coderabbit:extract', ['--pr' => '0']) - ->assertFailed() - ->expectsOutputToContain('positive integer'); -}); - -it('validates --min-severity must be valid', function (): void { - $this->artisan('coderabbit:extract', ['--pr' => '42', '--min-severity' => 'extreme']) - ->assertFailed() - ->expectsOutputToContain('Invalid --min-severity'); -}); - -it('fails when PR fetch returns null', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(42) - ->andReturn(null); - - $this->artisan('coderabbit:extract', ['--pr' => '42']) - ->assertFailed() - ->expectsOutputToContain('Failed to fetch PR #42'); -}); - -it('succeeds with no coderabbit comments found', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(10) - ->andReturn([ - 'comments' => [], - 'pr_title' => 'Test PR', - 'pr_url' => 'https://github.com/test/repo/pull/10', - ]); - - $this->artisan('coderabbit:extract', ['--pr' => '10']) - ->assertSuccessful() - ->expectsOutputToContain('No CodeRabbit review comments found'); -}); - -it('succeeds with no actionable findings from comments', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(10) - ->andReturn([ - 'comments' => [ - ['body' => '', 'path' => null, 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'Test PR', - 'pr_url' => 'https://github.com/test/repo/pull/10', - ]); - - $this->mockCodeRabbit->shouldReceive('parseFindings') - ->once() - ->andReturn([]); - - $this->artisan('coderabbit:extract', ['--pr' => '10']) - ->assertSuccessful() - ->expectsOutputToContain('No actionable findings'); -}); - -it('succeeds with no findings above severity threshold', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(10) - ->andReturn([ - 'comments' => [ - ['body' => 'Nit: fix typo', 'path' => 'src/app.php', 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'Test PR', - 'pr_url' => 'https://github.com/test/repo/pull/10', - ]); - - $this->mockCodeRabbit->shouldReceive('parseFindings') - ->once() - ->andReturn([ - ['title' => 'Fix typo', 'content' => 'Nit: fix typo', 'file' => 'src/app.php', 'severity' => 'low', 'confidence' => 45], - ]); - - $this->mockCodeRabbit->shouldReceive('filterBySeverity') - ->once() - ->with(Mockery::any(), 'high') - ->andReturn([]); - - $this->artisan('coderabbit:extract', ['--pr' => '10', '--min-severity' => 'high']) - ->assertSuccessful() - ->expectsOutputToContain('No findings meet the minimum severity threshold'); -}); - -it('extracts findings and adds to knowledge base', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(42) - ->andReturn([ - 'comments' => [ - ['body' => 'Bug: missing null check', 'path' => 'src/Service.php', 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'Add feature X', - 'pr_url' => 'https://github.com/test/repo/pull/42', - ]); - - $findings = [ - ['title' => 'Missing null check', 'content' => 'Bug: missing null check', 'file' => 'src/Service.php', 'severity' => 'high', 'confidence' => 75], - ]; - - $this->mockCodeRabbit->shouldReceive('parseFindings') - ->once() - ->andReturn($findings); - - $this->mockCodeRabbit->shouldReceive('filterBySeverity') - ->once() - ->with(Mockery::any(), 'low') - ->andReturn($findings); - - $this->mockQdrant->shouldReceive('upsert') - ->once() - ->with(Mockery::on(fn ($data): bool => $data['id'] === 'knowledge-pr-42-coderabbit-1' - && str_contains($data['title'], 'Missing null check') - && $data['priority'] === 'high' - && $data['confidence'] === 75 - && in_array('coderabbit', $data['tags'], true) - && in_array('pr-42', $data['tags'], true) - && $data['source'] === 'https://github.com/test/repo/pull/42' - ), 'default', false) - ->andReturn(true); - - $this->artisan('coderabbit:extract', ['--pr' => '42']) - ->assertSuccessful() - ->expectsOutputToContain('1 finding(s) added to knowledge base'); -}); - -it('handles upsert failures gracefully', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(42) - ->andReturn([ - 'comments' => [ - ['body' => 'Bug: issue found', 'path' => null, 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'PR Title', - 'pr_url' => 'https://github.com/test/repo/pull/42', - ]); - - $findings = [ - ['title' => 'Issue found', 'content' => 'Bug: issue found', 'file' => null, 'severity' => 'high', 'confidence' => 75], - ]; - - $this->mockCodeRabbit->shouldReceive('parseFindings')->once()->andReturn($findings); - $this->mockCodeRabbit->shouldReceive('filterBySeverity')->once()->andReturn($findings); - - $this->mockQdrant->shouldReceive('upsert') - ->once() - ->andThrow(new \RuntimeException('Connection failed')); - - $this->artisan('coderabbit:extract', ['--pr' => '42']) - ->assertSuccessful() - ->expectsOutputToContain('1 finding(s) failed to store'); -}); - -it('supports --dry-run flag', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(5) - ->andReturn([ - 'comments' => [ - ['body' => 'Consider refactoring this', 'path' => 'src/App.php', 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'Refactor', - 'pr_url' => 'https://github.com/test/repo/pull/5', - ]); - - $findings = [ - ['title' => 'Consider refactoring', 'content' => 'Consider refactoring this', 'file' => 'src/App.php', 'severity' => 'medium', 'confidence' => 60], - ]; - - $this->mockCodeRabbit->shouldReceive('parseFindings')->once()->andReturn($findings); - $this->mockCodeRabbit->shouldReceive('filterBySeverity')->once()->andReturn($findings); - - $this->mockQdrant->shouldNotReceive('upsert'); - - $this->artisan('coderabbit:extract', ['--pr' => '5', '--dry-run' => true]) - ->assertSuccessful() - ->expectsOutputToContain('Dry run complete'); -}); - -it('adds multiple findings with sequential semantic IDs', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(99) - ->andReturn([ - 'comments' => [ - ['body' => 'Security: SQL injection risk', 'path' => 'src/Query.php', 'author' => 'coderabbitai[bot]'], - ['body' => 'Nit: fix typo in variable name', 'path' => 'src/Helper.php', 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'Multiple findings', - 'pr_url' => 'https://github.com/test/repo/pull/99', - ]); - - $findings = [ - ['title' => 'SQL injection risk', 'content' => 'Security: SQL injection risk', 'file' => 'src/Query.php', 'severity' => 'critical', 'confidence' => 85], - ['title' => 'Fix typo', 'content' => 'Nit: fix typo in variable name', 'file' => 'src/Helper.php', 'severity' => 'low', 'confidence' => 45], - ]; - - $this->mockCodeRabbit->shouldReceive('parseFindings')->once()->andReturn($findings); - $this->mockCodeRabbit->shouldReceive('filterBySeverity')->once()->andReturn($findings); - - $capturedIds = []; - $this->mockQdrant->shouldReceive('upsert') - ->twice() - ->with(Mockery::on(function ($data) use (&$capturedIds): bool { - $capturedIds[] = $data['id']; - - return true; - }), 'default', false) - ->andReturn(true); - - $this->artisan('coderabbit:extract', ['--pr' => '99']) - ->assertSuccessful() - ->expectsOutputToContain('2 finding(s) added to knowledge base'); - - expect($capturedIds)->toBe([ - 'knowledge-pr-99-coderabbit-1', - 'knowledge-pr-99-coderabbit-2', - ]); -}); - -it('handles upsert returning false', function (): void { - $this->mockCodeRabbit->shouldReceive('fetchReviewComments') - ->once() - ->with(42) - ->andReturn([ - 'comments' => [ - ['body' => 'Some finding', 'path' => null, 'author' => 'coderabbitai[bot]'], - ], - 'pr_title' => 'PR Title', - 'pr_url' => 'https://github.com/test/repo/pull/42', - ]); - - $findings = [ - ['title' => 'Some finding', 'content' => 'Some finding', 'file' => null, 'severity' => 'medium', 'confidence' => 60], - ]; - - $this->mockCodeRabbit->shouldReceive('parseFindings')->once()->andReturn($findings); - $this->mockCodeRabbit->shouldReceive('filterBySeverity')->once()->andReturn($findings); - - $this->mockQdrant->shouldReceive('upsert') - ->once() - ->andReturn(false); - - $this->artisan('coderabbit:extract', ['--pr' => '42']) - ->assertSuccessful() - ->expectsOutputToContain('1 finding(s) failed to store'); -}); diff --git a/tests/Unit/Services/CodeRabbitServiceTest.php b/tests/Unit/Services/CodeRabbitServiceTest.php deleted file mode 100644 index 22ed592..0000000 --- a/tests/Unit/Services/CodeRabbitServiceTest.php +++ /dev/null @@ -1,352 +0,0 @@ - $responses Ordered responses for each runGhCommand call - */ -function fakeCodeRabbitService(array $responses): CodeRabbitService -{ - return new class($responses) extends CodeRabbitService - { - /** @var list */ - private array $responses; - - private int $callIndex = 0; - - /** @param list $responses */ - public function __construct(array $responses) - { - parent::__construct(); - $this->responses = $responses; - } - - protected function runGhCommand(array $command): Process - { - $response = $this->responses[$this->callIndex++] ?? ['success' => false, 'output' => '']; - $process = Mockery::mock(Process::class); - $process->shouldReceive('isSuccessful')->andReturn($response['success']); - $process->shouldReceive('getOutput')->andReturn($response['output']); - - return $process; - } - }; -} - -describe('fetchReviewComments', function (): void { - it('returns null when PR metadata fetch fails', function (): void { - $service = fakeCodeRabbitService([ - ['success' => false, 'output' => ''], - ]); - - expect($service->fetchReviewComments(1))->toBeNull(); - }); - - it('returns null when PR metadata returns invalid JSON', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => 'not json'], - ]); - - expect($service->fetchReviewComments(1))->toBeNull(); - }); - - it('returns null when both comment fetches fail', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'PR', 'url' => 'https://example.com/pr/1'])], - ['success' => false, 'output' => ''], - ['success' => false, 'output' => ''], - ]); - - expect($service->fetchReviewComments(1))->toBeNull(); - }); - - it('filters comments to coderabbit author only', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'Test PR', 'url' => 'https://example.com/pr/1'])], - ['success' => true, 'output' => json_encode([ - ['body' => 'LGTM', 'path' => null, 'author' => 'human-reviewer'], - ['body' => 'Found a bug', 'path' => null, 'author' => 'coderabbitai[bot]'], - ])], - ['success' => true, 'output' => json_encode([ - ['body' => 'Nit: naming', 'path' => 'src/Foo.php', 'author' => 'coderabbitai[bot]'], - ['body' => 'Looks good', 'path' => 'src/Bar.php', 'author' => 'other-bot'], - ])], - ]); - - $result = $service->fetchReviewComments(1); - - expect($result)->not->toBeNull(); - expect($result['pr_title'])->toBe('Test PR'); - expect($result['pr_url'])->toBe('https://example.com/pr/1'); - expect($result['comments'])->toHaveCount(2); - expect($result['comments'][0]['body'])->toBe('Found a bug'); - expect($result['comments'][1]['body'])->toBe('Nit: naming'); - }); - - it('returns empty comments when no coderabbit comments exist', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'PR', 'url' => 'https://example.com/pr/1'])], - ['success' => true, 'output' => json_encode([ - ['body' => 'LGTM', 'path' => null, 'author' => 'human'], - ])], - ['success' => true, 'output' => json_encode([])], - ]); - - $result = $service->fetchReviewComments(1); - - expect($result['comments'])->toHaveCount(0); - }); - - it('merges issue and review comments', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'PR', 'url' => 'https://example.com/pr/1'])], - ['success' => true, 'output' => json_encode([ - ['body' => 'Issue comment', 'path' => null, 'author' => 'coderabbitai[bot]'], - ])], - ['success' => true, 'output' => json_encode([ - ['body' => 'Review comment', 'path' => 'file.php', 'author' => 'coderabbitai[bot]'], - ])], - ]); - - $result = $service->fetchReviewComments(1); - - expect($result['comments'])->toHaveCount(2); - }); - - it('handles partial comment fetch failure gracefully', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'PR', 'url' => 'https://example.com/pr/1'])], - ['success' => false, 'output' => ''], - ['success' => true, 'output' => json_encode([ - ['body' => 'Review only', 'path' => 'file.php', 'author' => 'coderabbitai[bot]'], - ])], - ]); - - $result = $service->fetchReviewComments(1); - - expect($result)->not->toBeNull(); - expect($result['comments'])->toHaveCount(1); - }); - - it('handles invalid JSON in comment responses', function (): void { - $service = fakeCodeRabbitService([ - ['success' => true, 'output' => json_encode(['title' => 'PR', 'url' => 'https://example.com/pr/1'])], - ['success' => true, 'output' => 'broken json'], - ['success' => true, 'output' => json_encode([ - ['body' => 'Valid comment', 'path' => null, 'author' => 'coderabbitai[bot]'], - ])], - ]); - - $result = $service->fetchReviewComments(1); - - expect($result['comments'])->toHaveCount(1); - }); -}); - -describe('parseFindings', function (): void { - it('parses a single comment into a finding', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'Bug: This function has a missing validation that could fail', 'path' => 'src/Service.php', 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings)->toHaveCount(1); - expect($findings[0]['severity'])->toBe('high'); - expect($findings[0]['file'])->toBe('src/Service.php'); - expect($findings[0]['confidence'])->toBe(75); - }); - - it('splits multi-section comments into separate findings', function (): void { - $service = new CodeRabbitService; - - $body = <<<'MD' -## Security vulnerability in query builder - -SQL injection risk detected in raw query. - -## Nit: formatting issue - -Minor style inconsistency. -MD; - - $comments = [ - ['body' => $body, 'path' => 'src/Query.php', 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings)->toHaveCount(2); - expect($findings[0]['severity'])->toBe('critical'); - expect($findings[0]['title'])->toBe('Security vulnerability in query builder'); - expect($findings[1]['severity'])->toBe('low'); - }); - - it('skips empty comment bodies', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => '', 'path' => null, 'author' => 'coderabbitai[bot]'], - ['body' => ' ', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings)->toHaveCount(0); - }); - - it('handles comments without file path', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'Consider simplifying this logic', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings)->toHaveCount(1); - expect($findings[0]['file'])->toBeNull(); - }); - - it('detects critical severity from security keywords', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'This code has a security vulnerability that allows injection', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings[0]['severity'])->toBe('critical'); - expect($findings[0]['confidence'])->toBe(85); - }); - - it('detects high severity from bug keywords', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'This will fail with an error when input is empty', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings[0]['severity'])->toBe('high'); - expect($findings[0]['confidence'])->toBe(75); - }); - - it('detects medium severity from improvement keywords', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'Consider using a cleaner approach for readability', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings[0]['severity'])->toBe('medium'); - }); - - it('detects low severity from nit keywords', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'Nit: typo in variable naming convention', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings[0]['severity'])->toBe('low'); - expect($findings[0]['confidence'])->toBe(45); - }); - - it('defaults to medium severity for unrecognized content', function (): void { - $service = new CodeRabbitService; - - $comments = [ - ['body' => 'Interesting approach here, looks reasonable', 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect($findings[0]['severity'])->toBe('medium'); - }); - - it('truncates long titles', function (): void { - $service = new CodeRabbitService; - - $longTitle = str_repeat('A very long title that should be truncated ', 10); - $comments = [ - ['body' => $longTitle, 'path' => null, 'author' => 'coderabbitai[bot]'], - ]; - - $findings = $service->parseFindings($comments); - - expect(mb_strlen($findings[0]['title']))->toBeLessThanOrEqual(120); - }); -}); - -describe('filterBySeverity', function (): void { - it('filters findings below minimum severity', function (): void { - $service = new CodeRabbitService; - - $findings = [ - ['title' => 'Critical', 'content' => 'Critical issue', 'file' => null, 'severity' => 'critical', 'confidence' => 85], - ['title' => 'High', 'content' => 'High issue', 'file' => null, 'severity' => 'high', 'confidence' => 75], - ['title' => 'Medium', 'content' => 'Medium issue', 'file' => null, 'severity' => 'medium', 'confidence' => 60], - ['title' => 'Low', 'content' => 'Low issue', 'file' => null, 'severity' => 'low', 'confidence' => 45], - ]; - - $filtered = $service->filterBySeverity($findings, 'high'); - - expect($filtered)->toHaveCount(2); - expect($filtered[0]['severity'])->toBe('critical'); - expect($filtered[1]['severity'])->toBe('high'); - }); - - it('returns all findings when minimum is low', function (): void { - $service = new CodeRabbitService; - - $findings = [ - ['title' => 'Critical', 'content' => 'Critical', 'file' => null, 'severity' => 'critical', 'confidence' => 85], - ['title' => 'Low', 'content' => 'Low', 'file' => null, 'severity' => 'low', 'confidence' => 45], - ]; - - $filtered = $service->filterBySeverity($findings, 'low'); - - expect($filtered)->toHaveCount(2); - }); - - it('returns only critical when minimum is critical', function (): void { - $service = new CodeRabbitService; - - $findings = [ - ['title' => 'Critical', 'content' => 'Critical', 'file' => null, 'severity' => 'critical', 'confidence' => 85], - ['title' => 'High', 'content' => 'High', 'file' => null, 'severity' => 'high', 'confidence' => 75], - ['title' => 'Low', 'content' => 'Low', 'file' => null, 'severity' => 'low', 'confidence' => 45], - ]; - - $filtered = $service->filterBySeverity($findings, 'critical'); - - expect($filtered)->toHaveCount(1); - expect($filtered[0]['severity'])->toBe('critical'); - }); - - it('returns empty array when no findings meet threshold', function (): void { - $service = new CodeRabbitService; - - $findings = [ - ['title' => 'Low', 'content' => 'Low', 'file' => null, 'severity' => 'low', 'confidence' => 45], - ]; - - $filtered = $service->filterBySeverity($findings, 'critical'); - - expect($filtered)->toHaveCount(0); - }); -});