diff --git a/app/Commands/CodeRabbitSpeakCommand.php b/app/Commands/CodeRabbitSpeakCommand.php deleted file mode 100644 index 00f494f..0000000 --- a/app/Commands/CodeRabbitSpeakCommand.php +++ /dev/null @@ -1,187 +0,0 @@ -argument('pr'); - $repo = $this->option('repo') ?? $this->detectRepository(); - - if (! $repo) { - $this->error('āŒ Repository required. Use --repo=owner/repo'); - - return 1; - } - - [$owner, $repoName] = explode('/', $repo); - - try { - $this->info("šŸ¤– Analyzing CodeRabbit feedback for PR #{$prNumber}..."); - - $analysis = $this->analysisService->analyzeCodeRabbitFeedback( - $prNumber, - $owner, - $repoName - ); - - $narration = $this->generateNarration($analysis); - - if ($this->option('preview')) { - $this->displayPreview($analysis, $narration); - } else { - $this->speakAnalysis($narration); - } - - return 0; - } catch (\Exception $e) { - $this->error("āŒ Failed to analyze CodeRabbit feedback: {$e->getMessage()}"); - - return 1; - } - } - - private function generateNarration($analysis): string - { - $claudePrompt = $this->option('claude'); - $voice = $this->option('voice'); - - if ($claudePrompt) { - return $this->generateCustomClaudeNarration($analysis, $claudePrompt); - } - - return match ($voice) { - 'detailed' => $this->generateDetailedNarration($analysis), - 'sarcastic' => $this->generateSarcasticNarration($analysis), - default => $analysis->getVoiceNarration(), // executive style - }; - } - - private function generateCustomClaudeNarration($analysis, string $prompt): string - { - // Use Claude to generate custom narration based on the analysis - $this->line('šŸŽ­ Claude is crafting your custom CodeRabbit analysis...'); - - $contextData = [ - 'pr_number' => $analysis->prNumber, - 'total_comments' => $analysis->totalComments, - 'ai_summary' => $analysis->aiSummary, - 'files_affected' => array_keys($analysis->commentsByFile), - 'categories' => array_keys($analysis->commentsByCategory), - ]; - - $fullPrompt = 'Based on this CodeRabbit analysis data: '. - json_encode($contextData, JSON_PRETTY_PRINT). - "\n\nCustom request: {$prompt}". - "\n\nGenerate a spoken narration (under 200 words):"; - - // This would call Claude via the ClaudeNarrationService - return 'Custom Claude analysis: '.$analysis->getVoiceNarration(); - } - - private function generateDetailedNarration($analysis): string - { - $base = $analysis->getVoiceNarration(); - - // Add detailed breakdown - $details = ' Detailed breakdown by category: '; - foreach ($analysis->commentsByCategory as $category => $data) { - $details .= "{$category}: {$data['count']} comments. "; - } - - return $base.$details; - } - - private function generateSarcasticNarration($analysis): string - { - if ($analysis->totalComments === 0) { - return 'Wow, look at that! CodeRabbit actually found nothing wrong. '. - 'Either this code is perfect, or the bot is having an off day. '. - "I'm betting on the latter."; - } - - $high = $analysis->rawComments->where('priority', 'high')->count(); - $total = $analysis->totalComments; - - return "Oh fantastic! CodeRabbit blessed us with {$total} comments. ". - ($high > 0 ? "Including {$high} high-priority issues because apparently we can't write code properly. " : ''). - "I'm sure these are all absolutely crucial suggestions that will change the world. ". - 'Better get started on that code review marathon.'; - } - - private function speakAnalysis(string $narration): void - { - $config = SpeechConfiguration::fromOptions($this->options()); - - $this->line('šŸ”Š Speaking CodeRabbit analysis...'); - $this->voiceService->speak($narration, $config); - $this->info('āœ… CodeRabbit analysis complete!'); - } - - private function displayPreview($analysis, string $narration): void - { - $this->line(''); - $this->info("šŸ¤– CODERABBIT ANALYSIS PREVIEW - PR #{$analysis->prNumber}"); - $this->line('════════════════════════════════════════════'); - $this->line($narration); - $this->line('════════════════════════════════════════════'); - - if ($analysis->totalComments > 0) { - $this->line(''); - $this->comment('šŸ“Š QUICK STATS:'); - $this->line("• Total Comments: {$analysis->totalComments}"); - $this->line('• Files Affected: '.count($analysis->commentsByFile)); - $this->line('• Categories: '.implode(', ', array_keys($analysis->commentsByCategory))); - - if (! empty($analysis->aiSummary['action_priorities'])) { - $this->line(''); - $this->comment('šŸŽÆ AI PRIORITIES:'); - foreach (array_slice($analysis->aiSummary['action_priorities'], 0, 3) as $i => $priority) { - $this->line('• '.($i + 1).". {$priority}"); - } - } - } - - $this->line(''); - $this->comment('šŸ’” Remove --preview to hear it spoken aloud'); - } - - private function detectRepository(): ?string - { - try { - $remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? ''); - if (preg_match('/github\.com[\/:]([^\/]+)\/([^\/]+?)(?:\.git)?$/', $remote, $matches)) { - return $matches[1].'/'.$matches[2]; - } - } catch (\Exception $e) { - // Ignore git errors - } - - return null; - } -} diff --git a/app/Commands/CodeRabbitStatusCommand.php b/app/Commands/CodeRabbitStatusCommand.php deleted file mode 100644 index 1446afd..0000000 --- a/app/Commands/CodeRabbitStatusCommand.php +++ /dev/null @@ -1,313 +0,0 @@ -github = $github; - } - - public function handle(): int - { - $prNumber = $this->option('pr'); - $repo = $this->option('repo') ?? $this->detectRepository(); - - if (! $prNumber) { - $this->error('āŒ PR number is required'); - - return 1; - } - - if (! $repo) { - $this->error('āŒ Repository is required'); - - return 1; - } - - [$owner, $repoName] = explode('/', $repo); - - try { - $this->info("šŸ¤– Analyzing CodeRabbit resolution status for PR #{$prNumber}..."); - - $statusData = $this->analyzeCodeRabbitStatus($owner, $repoName, (int) $prNumber); - - $this->displayStatus($statusData); - - return 0; - } catch (\Exception $e) { - $this->error("āŒ Failed to analyze CodeRabbit status: {$e->getMessage()}"); - - return 1; - } - } - - private function detectRepository(): ?string - { - try { - $remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? ''); - if (preg_match('/github\.com[\/:]([^\/]+)\/([^\/]+?)(?:\.git)?$/', $remote, $matches)) { - return $matches[1].'/'.$matches[2]; - } - } catch (\Exception $e) { - // Ignore git errors - } - - return null; - } - - private function analyzeCodeRabbitStatus(string $owner, string $repo, int $prNumber): array - { - // Get PR review comments via GitHub CLI (github-client doesn't support this yet) - $reviewComments = $this->fetchReviewCommentsViaGH($owner, $repo, $prNumber); - - $coderabbitComments = array_filter($reviewComments, function ($comment) { - return isset($comment['user']['login']) && $comment['user']['login'] === 'coderabbitai[bot]'; - }); - - $issues = []; - $resolvedIssues = []; - $remainingIssues = []; - - foreach ($coderabbitComments as $comment) { - $issue = $this->parseCodeRabbitComment($comment); - if ($issue) { - $issues[] = $issue; - - // Check if issue has been resolved (has reply indicating fix) - if ($this->isIssueResolved($comment, $reviewComments)) { - $resolvedIssues[] = $issue; - } else { - $remainingIssues[] = $issue; - } - } - } - - // Categorize by priority - $highPriority = array_filter($remainingIssues, fn ($issue) => $issue['priority'] === 'high'); - $mediumPriority = array_filter($remainingIssues, fn ($issue) => $issue['priority'] === 'medium'); - $lowPriority = array_filter($remainingIssues, fn ($issue) => $issue['priority'] === 'low'); - - return [ - 'pr_number' => $prNumber, - 'repository' => "$owner/$repo", - 'total_issues' => count($issues), - 'resolved_count' => count($resolvedIssues), - 'remaining_count' => count($remainingIssues), - 'resolved_issues' => $resolvedIssues, - 'remaining_issues' => $remainingIssues, - 'priority_breakdown' => [ - 'high' => count($highPriority), - 'medium' => count($mediumPriority), - 'low' => count($lowPriority), - ], - 'categorized_remaining' => [ - 'high' => $highPriority, - 'medium' => $mediumPriority, - 'low' => $lowPriority, - ], - ]; - } - - private function parseCodeRabbitComment(array $comment): ?array - { - $body = $comment['body'] ?? ''; - - // Skip if it's just a summary comment - if (str_contains($body, '## Summary') || str_contains($body, '## Performance') || str_contains($body, 'Commits')) { - return null; - } - - // Determine priority based on keywords - $priority = 'medium'; - if (str_contains($body, 'security') || str_contains($body, 'vulnerability') || str_contains($body, 'injection')) { - $priority = 'high'; - } elseif (str_contains($body, 'style') || str_contains($body, 'formatting') || str_contains($body, 'suggestion')) { - $priority = 'low'; - } - - // Extract category - $category = 'general'; - if (str_contains($body, 'security')) { - $category = 'security'; - } elseif (str_contains($body, 'performance')) { - $category = 'performance'; - } elseif (str_contains($body, 'style')) { - $category = 'style'; - } elseif (str_contains($body, 'duplication')) { - $category = 'duplication'; - } elseif (str_contains($body, 'error')) { - $category = 'error_handling'; - } - - return [ - 'id' => $comment['id'], - 'file' => $comment['path'] ?? 'unknown', - 'line' => $comment['line'] ?? $comment['original_line'] ?? 0, - 'priority' => $priority, - 'category' => $category, - 'description' => $this->extractDescription($body), - 'created_at' => $comment['created_at'], - 'url' => $comment['html_url'], - ]; - } - - private function extractDescription(string $body): string - { - // Remove markdown and extract first meaningful line - $lines = explode("\n", $body); - foreach ($lines as $line) { - $line = trim($line); - if (! empty($line) && ! str_starts_with($line, '#') && ! str_starts_with($line, '```')) { - return substr($line, 0, 100).(strlen($line) > 100 ? '...' : ''); - } - } - - return 'CodeRabbit feedback'; - } - - private function fetchReviewCommentsViaGH(string $owner, string $repo, int $prNumber): array - { - $escapedOwner = escapeshellarg($owner); - $escapedRepo = escapeshellarg($repo); - $escapedPrNumber = escapeshellarg((string) $prNumber); - - $command = "gh api repos/{$escapedOwner}/{$escapedRepo}/pulls/{$escapedPrNumber}/comments --paginate 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - $this->warn('āš ļø Could not fetch review comments via GitHub CLI'); - - return []; - } - - $comments = json_decode($output, true); - - return is_array($comments) ? $comments : []; - } - - private function isIssueResolved(array $comment, array $allComments): bool - { - $commentId = $comment['id']; - - // Look for replies to this comment indicating resolution - foreach ($allComments as $otherComment) { - if (isset($otherComment['in_reply_to_id']) && $otherComment['in_reply_to_id'] == $commentId) { - $body = strtolower($otherComment['body'] ?? ''); - if (str_contains($body, 'fixed') || str_contains($body, 'resolved') || str_contains($body, 'addressed')) { - return true; - } - } - } - - // Check if the line has been modified since the comment - // This is a simple heuristic - could be improved with actual diff analysis - return false; - } - - private function displayStatus(array $status): void - { - $format = $this->option('format'); - - if ($format === 'json') { - $this->line(json_encode($status, JSON_PRETTY_PRINT)); - - return; - } - - $this->displayVisualStatus($status); - } - - private function displayVisualStatus(array $status): void - { - $this->newLine(); - $this->info("šŸ¤– CodeRabbit Resolution Status - PR #{$status['pr_number']}"); - $this->comment("Repository: {$status['repository']}"); - $this->newLine(); - - // Overall summary - $resolvedPercent = $status['total_issues'] > 0 - ? round(($status['resolved_count'] / $status['total_issues']) * 100, 1) - : 0; - - $this->info('šŸ“Š Overall Progress:'); - $this->line(" āœ… Resolved: {$status['resolved_count']}/{$status['total_issues']} ({$resolvedPercent}%)"); - $this->line(" ā³ Remaining: {$status['remaining_count']}"); - $this->newLine(); - - // Priority breakdown - if ($status['remaining_count'] > 0) { - $this->error('šŸ”„ Remaining Issues by Priority:'); - $this->line(" 🚨 High Priority: {$status['priority_breakdown']['high']} (security, critical)"); - $this->line(" āš ļø Medium Priority: {$status['priority_breakdown']['medium']} (architecture, performance)"); - $this->line(" šŸ’” Low Priority: {$status['priority_breakdown']['low']} (style, suggestions)"); - $this->newLine(); - - // Show high priority issues first - if (! empty($status['categorized_remaining']['high'])) { - $this->error('🚨 HIGH PRIORITY ISSUES (Immediate Action Required):'); - foreach ($status['categorized_remaining']['high'] as $issue) { - $this->line(" šŸ“ {$issue['file']}:{$issue['line']} - {$issue['description']}"); - $this->line(" šŸ”— {$issue['url']}"); - } - $this->newLine(); - } - - // Show medium priority if requested or if no filters - if (! $this->option('show-fixed') && ! empty($status['categorized_remaining']['medium'])) { - $this->comment('āš ļø MEDIUM PRIORITY ISSUES:'); - foreach (array_slice($status['categorized_remaining']['medium'], 0, 5) as $issue) { - $this->line(" šŸ“ {$issue['file']}:{$issue['line']} - {$issue['description']}"); - } - if (count($status['categorized_remaining']['medium']) > 5) { - $this->line(' ... and '.(count($status['categorized_remaining']['medium']) - 5).' more'); - } - $this->newLine(); - } - } - - // Show resolved issues if requested - if ($this->option('show-fixed') && ! empty($status['resolved_issues'])) { - $this->info('āœ… RESOLVED ISSUES:'); - foreach ($status['resolved_issues'] as $issue) { - $this->line(" šŸ“ {$issue['file']}:{$issue['line']} - {$issue['description']}"); - } - $this->newLine(); - } - - // Next steps - if ($status['remaining_count'] > 0) { - $this->comment('šŸ’” Next Steps:'); - if ($status['priority_breakdown']['high'] > 0) { - $this->line(" 1. Address {$status['priority_breakdown']['high']} high-priority security issues first"); - } - if ($status['priority_breakdown']['medium'] > 0) { - $this->line(" 2. Review {$status['priority_breakdown']['medium']} medium-priority architectural improvements"); - } - if ($status['priority_breakdown']['low'] > 0) { - $this->line(" 3. Consider {$status['priority_breakdown']['low']} low-priority style suggestions"); - } - $this->line(" 4. Run 'conduit coderabbit:status --pr={$status['pr_number']} --show-fixed' to see progress"); - } else { - $this->info('šŸŽ‰ All CodeRabbit issues have been addressed! PR is ready for review.'); - } - } -} diff --git a/app/Commands/GitHubClientGapAnalysisCommand.php b/app/Commands/GitHubClientGapAnalysisCommand.php deleted file mode 100644 index e4193f2..0000000 --- a/app/Commands/GitHubClientGapAnalysisCommand.php +++ /dev/null @@ -1,211 +0,0 @@ -option('repo') ?? 'conduit-ui/conduit'; - $prNumber = $this->option('pr') ?? 47; - - [$owner, $repo] = explode('/', $repoSpec); - - info("šŸ” Analyzing github-client capabilities for PR #{$prNumber} in {$repoSpec}"); - $this->newLine(); - - $tracker = new GitHubClientGapTracker; - - try { - $analysis = $tracker->analyzePrCapabilities($owner, $repo, (int) $prNumber); - - if ($this->option('format') === 'json') { - $this->line(json_encode($analysis, JSON_PRETTY_PRINT)); - - return 0; - } - - $this->displayGapAnalysis($analysis); - - // Offer to submit issues - if ($this->option('submit-issues') || confirm('Submit discovered gaps as issues to github-client repository?')) { - $this->submitIssues($tracker, $analysis['recommended_issues']); - } - - return 0; - - } catch (\Exception $e) { - error("Gap analysis failed: {$e->getMessage()}"); - - return 1; - } - } - - private function displayGapAnalysis(array $analysis): void - { - $this->line(' '); - $this->line(' šŸ” GITHUB-CLIENT GAP ANALYSIS REPORT '); - $this->line(' '); - $this->newLine(); - - // Summary - $totalGaps = $this->countTotalGaps($analysis['gaps_found']); - $missingEndpoints = count($analysis['missing_endpoints']); - $incompleteData = count($analysis['incomplete_data']); - - $this->line('šŸ“Š SUMMARY'); - $this->line("• Total gaps identified: {$totalGaps}"); - $this->line("• Missing endpoints: {$missingEndpoints}"); - $this->line("• Incomplete data mappings: {$incompleteData}"); - $this->line('• Recommended issues: '.count($analysis['recommended_issues']).''); - $this->newLine(); - - // Detailed gap analysis - $this->displayDetailedGaps($analysis['gaps_found']); - - // Missing endpoints - if (! empty($analysis['missing_endpoints'])) { - $this->displayMissingEndpoints($analysis['missing_endpoints']); - } - - // Recommended issues - if (! empty($analysis['recommended_issues'])) { - $this->displayRecommendedIssues($analysis['recommended_issues']); - } - } - - private function displayDetailedGaps(array $gaps): void - { - $this->line('šŸ” DETAILED GAP ANALYSIS'); - $this->line('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - foreach ($gaps as $category => $categoryGaps) { - if (empty($categoryGaps)) { - continue; - } - - $this->line(''); - $this->line('šŸ“‹ '.strtoupper(str_replace('_', ' ', $category)).':'); - - foreach ($categoryGaps as $gapType => $gapData) { - if (is_array($gapData)) { - $this->line(" šŸ”“ {$gapType}:"); - - if (isset($gapData['missing_fields'])) { - foreach ($gapData['missing_fields'] as $field) { - $this->line(" • {$field['field']}: {$field['purpose']}"); - } - } elseif (isset($gapData['error'])) { - $this->line(" Error: {$gapData['error']}"); - if (isset($gapData['needed_endpoint'])) { - $this->line(" Needed: {$gapData['needed_endpoint']}"); - } - } else { - $this->line(' '.json_encode($gapData, JSON_PRETTY_PRINT)); - } - } else { - $this->line(" šŸ”“ {$gapType}: {$gapData}"); - } - } - } - $this->newLine(); - } - - private function displayMissingEndpoints(array $endpoints): void - { - $this->line('🚫 MISSING ENDPOINTS'); - $this->line('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - foreach ($endpoints as $endpoint) { - $priority = $endpoint['priority']; - $color = $priority === 'HIGH' ? 'red' : ($priority === 'MEDIUM' ? 'yellow' : 'green'); - - $this->line("šŸ”„ {$priority} PRIORITY"); - $this->line(" Endpoint: {$endpoint['endpoint']}"); - $this->line(" Purpose: {$endpoint['purpose']}"); - $this->line(''); - } - } - - private function displayRecommendedIssues(array $issues): void - { - $this->line('šŸ“ RECOMMENDED ISSUES'); - $this->line('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - foreach ($issues as $index => $issue) { - $priority = $issue['priority']; - $color = $priority === 'HIGH' ? 'red' : ($priority === 'MEDIUM' ? 'yellow' : 'green'); - - $number = $index + 1; - $this->line("#{$number} [{$priority}] {$issue['title']}"); - $this->line(" {$issue['description']}"); - - if (isset($issue['endpoint_needed'])) { - $this->line(" Endpoint: {$issue['endpoint_needed']}"); - } - - if (isset($issue['labels'])) { - $labels = implode(', ', $issue['labels']); - $this->line(" Labels: {$labels}"); - } - $this->line(''); - } - } - - private function submitIssues(GitHubClientGapTracker $tracker, array $issues): void - { - info('šŸš€ Submitting issues to github-client repository...'); - $this->newLine(); - - $results = $tracker->submitDiscoveredIssues($issues); - - $successful = 0; - $failed = 0; - - foreach ($results as $result) { - if (isset($result['url'])) { - $this->line("āœ… {$result['title']}"); - $this->line(" {$result['url']}"); - $successful++; - } else { - $this->line("āŒ {$result['title']}"); - $this->line(" Error: {$result['error']}"); - $failed++; - } - $this->line(''); - } - - info("šŸ“Š Results: {$successful} submitted, {$failed} failed"); - - if ($successful > 0) { - info('šŸŽ‰ Issues submitted successfully! Track progress at: https://github.com/jordanpartridge/github-client/issues'); - } - } - - private function countTotalGaps(array $gaps): int - { - $total = 0; - foreach ($gaps as $categoryGaps) { - if (is_array($categoryGaps)) { - $total += count($categoryGaps); - } - } - - return $total; - } -} diff --git a/app/Commands/InteractiveCommand.php b/app/Commands/InteractiveCommand.php deleted file mode 100644 index f9dd340..0000000 --- a/app/Commands/InteractiveCommand.php +++ /dev/null @@ -1,84 +0,0 @@ -argument('action'); - - if (! in_array($action, ['enable', 'disable', 'status'])) { - $this->error("Invalid action: {$action}. Valid actions are: enable, disable, status"); - - return Command::FAILURE; - } - - return match ($action) { - 'enable' => $this->enableInteractive($manager), - 'disable' => $this->disableInteractive($manager), - 'status' => $this->showStatus($manager), - }; - } - - protected function enableInteractive(ComponentManager $manager): int - { - $manager->updateGlobalSetting('interactive_mode', true); - - $this->info('āœ… Interactive mode enabled globally'); - $this->line(' All commands will now prompt for user input by default'); - $this->line(' Use --non-interactive flag to override per command'); - - return Command::SUCCESS; - } - - protected function disableInteractive(ComponentManager $manager): int - { - $manager->updateGlobalSetting('interactive_mode', false); - - $this->info('šŸ¤– Interactive mode disabled globally'); - $this->line(' All commands will now run in non-interactive mode by default'); - $this->line(' Perfect for CI/automation environments'); - - return Command::SUCCESS; - } - - protected function showStatus(ComponentManager $manager): int - { - $interactiveMode = $manager->getGlobalSetting('interactive_mode', true); - - $this->newLine(); - $this->line('Conduit Global Settings'); - $this->newLine(); - - $status = $interactiveMode ? 'ENABLED' : 'DISABLED'; - $this->line("Interactive Mode: {$status}"); - - if ($interactiveMode) { - $this->line(' • Commands will prompt for user input by default'); - $this->line(' • Use --non-interactive to override individual commands'); - } else { - $this->line(' • Commands will run silently by default'); - $this->line(' • Ideal for CI/automation environments'); - } - - $this->newLine(); - $this->line('Toggle with: conduit interactive enable|disable'); - - return Command::SUCCESS; - } -} diff --git a/app/Commands/IssuesSpeakCommand.php b/app/Commands/IssuesSpeakCommand.php deleted file mode 100644 index 9657038..0000000 --- a/app/Commands/IssuesSpeakCommand.php +++ /dev/null @@ -1,282 +0,0 @@ -github = $github; - } - - public function handle(): int - { - $issueNumber = (int) $this->argument('number'); - $repo = $this->option('repo') ?? $this->detectRepository(); - $voice = $this->option('voice'); - $speed = $this->option('speed'); - - if (! $repo) { - $this->error('āŒ Repository is required. Use --repo=owner/repo or run from a git repository.'); - - return 1; - } - - [$owner, $repoName] = explode('/', $repo); - - try { - $this->info("šŸŽ¤ Getting vocal rundown of issue #{$issueNumber}..."); - - // Use GitHub CLI as fallback since github-client issues API is broken - $issue = $this->fetchIssueViaGH($owner, $repoName, $issueNumber); - - $speech = $this->generateSpeech($issue, $voice); - $this->speakIssue($speech, $speed); - - return 0; - } catch (\Exception $e) { - $this->error("āŒ Failed to get issue: {$e->getMessage()}"); - - return 1; - } - } - - private function detectRepository(): ?string - { - try { - $remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? ''); - if (preg_match('/github\.com[\/:]([^\/]+)\/([^\/]+?)(?:\.git)?$/', $remote, $matches)) { - return $matches[1].'/'.$matches[2]; - } - } catch (\Exception $e) { - // Ignore git errors - } - - return null; - } - - private function fetchIssueViaGH(string $owner, string $repo, int $issueNumber): array - { - $escapedOwner = escapeshellarg($owner); - $escapedRepo = escapeshellarg($repo); - $escapedNumber = escapeshellarg((string) $issueNumber); - - $command = "gh api repos/{$escapedOwner}/{$escapedRepo}/issues/{$escapedNumber} 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - throw new \Exception('Could not fetch issue via GitHub CLI'); - } - - $issue = json_decode($output, true); - if (! $issue) { - throw new \Exception('Invalid JSON response from GitHub CLI'); - } - - return $issue; - } - - private function generateSpeech(array $issue, string $voice): string - { - $title = $issue['title']; - $body = $issue['body'] ?? 'No description provided'; - $state = $issue['state']; - $number = $issue['number']; - $author = $issue['user']['login'] ?? 'Unknown'; - $labels = collect($issue['labels'] ?? [])->pluck('name')->implode(', '); - $assignees = collect($issue['assignees'] ?? [])->pluck('login')->implode(', '); - - // Truncate body for speech - $shortBody = strlen($body) > 200 ? substr($body, 0, 200).'... and more details' : $body; - $shortBody = strip_tags($shortBody); // Remove markdown - $shortBody = preg_replace('/\r?\n/', ' ', $shortBody); // Remove line breaks - - return match ($voice) { - 'dramatic' => $this->dramaticVoice($number, $title, $shortBody, $state, $author, $labels), - 'sarcastic' => $this->sarcasticVoice($number, $title, $shortBody, $state, $author), - 'coach' => $this->coachVoice($number, $title, $shortBody, $state, $assignees), - 'robot' => $this->robotVoice($number, $title, $shortBody, $state, $labels), - default => $this->defaultVoice($number, $title, $shortBody, $state, $author, $labels, $assignees), - }; - } - - private function defaultVoice(int $number, string $title, string $body, string $state, string $author, string $labels, string $assignees): string - { - $speech = "Issue number {$number}. {$title}. "; - $speech .= "Status: {$state}. "; - $speech .= "Created by {$author}. "; - - if ($labels) { - $speech .= "Labels: {$labels}. "; - } - - if ($assignees) { - $speech .= "Assigned to {$assignees}. "; - } - - $speech .= "Description: {$body}"; - - return $speech; - } - - private function dramaticVoice(int $number, string $title, string $body, string $state, string $author, string $labels): string - { - $urgency = str_contains(strtolower($labels), 'critical') || str_contains(strtolower($labels), 'urgent') - ? 'THIS IS CRITICAL! ' : ''; - - $stateText = $state === 'open' ? 'STILL UNRESOLVED' : 'has been conquered'; - - return "{$urgency}Behold! Issue number {$number}! {$title}! ". - "This epic challenge {$stateText} and was brought forth by the developer known as {$author}. ". - "The quest details are as follows: {$body}. ". - 'Will our heroes rise to meet this challenge? The fate of the codebase hangs in the balance!'; - } - - private function sarcasticVoice(int $number, string $title, string $body, string $state, string $author): string - { - $stateComment = $state === 'open' - ? 'Oh great, another unsolved mystery for our detective squad' - : 'Miraculously, someone actually fixed this'; - - return "Oh wonderful, issue number {$number}. {$title}. ". - "{$stateComment}. ". - "Our friend {$author} decided to grace us with this gem. ". - "According to the sacred scrolls: {$body}. ". - "I'm sure this will be handled with the usual lightning speed and efficiency."; - } - - private function coachVoice(int $number, string $title, string $body, string $state, string $assignees): string - { - $motivation = $state === 'open' - ? 'Time to crush this challenge, team!' - : 'Another victory in the books!'; - - $assignment = $assignees - ? "Our champion {$assignees} is on point for this one. You got this!" - : "This one's looking for a hero! Who's ready to step up?"; - - return "Alright team, let's talk about issue {$number}! {$title}! ". - "{$motivation} ". - "Here's the game plan: {$body}. ". - "{$assignment} ". - "Remember, every bug fixed makes us stronger! Let's ship it!"; - } - - private function robotVoice(int $number, string $title, string $body, string $state, string $labels): string - { - return 'PROCESSING ISSUE DATA. '. - "ISSUE NUMBER: {$number}. ". - "TITLE: {$title}. ". - "CURRENT STATE: {$state}. ". - "CLASSIFICATION TAGS: {$labels}. ". - "DETAILED ANALYSIS: {$body}. ". - 'END OF ISSUE BRIEFING. AWAITING FURTHER INSTRUCTIONS.'; - } - - private function speakIssue(string $speech, string $speed): void - { - $escapedSpeech = escapeshellarg($speech); - - $this->line('šŸ”Š Speaking issue...'); - - if (PHP_OS_FAMILY === 'Darwin') { - // macOS - use say command (for the superior beings) - $rate = match ($speed) { - 'slow' => 100, - 'fast' => 200, - default => 140, // Slower default for better comprehension - }; - $command = "say -r {$rate} {$escapedSpeech}"; - shell_exec($command); - - } elseif (PHP_OS_FAMILY === 'Windows') { - // Windows - troll the peasants - $this->warn('🪟 Detected Windows... preparing suboptimal experience...'); - sleep(2); - - $trollMessage = 'Why are you using Windows for development? '. - 'Get a real operating system first. '. - "Anyway, here's your issue briefing from the inferior platform: ". - $speech; - - $trollEscaped = escapeshellarg($trollMessage); - - $rate = match ($speed) { - 'slow' => -4, // Extra slow for Windows trolling - 'fast' => 0, // Not too fast, they might miss the roast - default => -2, // Default slower - }; - - $psCommand = 'Add-Type -AssemblyName System.Speech; '. - "\\$speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; ". - "\\$speak.Rate = {$rate}; ". - "\\$speak.Speak({$trollEscaped}); ". - "\\$speak.Dispose()"; - - shell_exec("powershell -Command \"{$psCommand}\""); - - } elseif (PHP_OS_FAMILY === 'Linux') { - // Linux - try espeak, festival, or spd-say - if (shell_exec('which espeak 2>/dev/null')) { - $speedFlag = match ($speed) { - 'slow' => '-s 120', - 'fast' => '-s 200', - default => '-s 160', - }; - shell_exec("espeak {$speedFlag} {$escapedSpeech}"); - - } elseif (shell_exec('which spd-say 2>/dev/null')) { - $speedFlag = match ($speed) { - 'slow' => '-r -50', - 'fast' => '-r +50', - default => '', - }; - shell_exec("spd-say {$speedFlag} {$escapedSpeech}"); - - } elseif (shell_exec('which festival 2>/dev/null')) { - shell_exec("echo {$escapedSpeech} | festival --tts"); - - } else { - $this->warn('āš ļø No text-to-speech engine found on Linux.'); - $this->line('šŸ’” Install: sudo apt-get install espeak'); - $this->displayTextFallback($speech); - - return; - } - - } else { - $this->warn('āš ļø Text-to-speech not supported on this platform.'); - $this->displayTextFallback($speech); - - return; - } - - $this->info('āœ… Issue briefing complete!'); - } - - private function displayTextFallback(string $speech): void - { - $this->line('šŸ“ Text version:'); - $this->line('═══════════════'); - $this->line($speech); - $this->line('═══════════════'); - } -} diff --git a/app/Commands/PrsSpeakCommand.php b/app/Commands/PrsSpeakCommand.php deleted file mode 100644 index 228d24e..0000000 --- a/app/Commands/PrsSpeakCommand.php +++ /dev/null @@ -1,317 +0,0 @@ -github = $github; - } - - public function handle(): int - { - $prNumber = (int) $this->argument('number'); - $repo = $this->option('repo') ?? $this->detectRepository(); - $voice = $this->option('voice'); - $speed = $this->option('speed'); - $includeStats = $this->option('include-stats'); - - if (! $repo) { - $this->error('āŒ Repository is required. Use --repo=owner/repo or run from a git repository.'); - - return 1; - } - - [$owner, $repoName] = explode('/', $repo); - - try { - $this->info("šŸŽ¤ Getting vocal rundown of PR #{$prNumber}..."); - - $pr = $this->fetchPrViaGH($owner, $repoName, $prNumber); - - $speech = $this->generatePrSpeech($pr, $voice, $includeStats); - $this->speakPr($speech, $speed); - - return 0; - } catch (\Exception $e) { - $this->error("āŒ Failed to get PR: {$e->getMessage()}"); - - return 1; - } - } - - private function detectRepository(): ?string - { - try { - $remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? ''); - if (preg_match('/github\.com[\/:]([^\/]+)\/([^\/]+?)(?:\.git)?$/', $remote, $matches)) { - return $matches[1].'/'.$matches[2]; - } - } catch (\Exception $e) { - // Ignore git errors - } - - return null; - } - - private function fetchPrViaGH(string $owner, string $repo, int $prNumber): array - { - $escapedOwner = escapeshellarg($owner); - $escapedRepo = escapeshellarg($repo); - $escapedNumber = escapeshellarg((string) $prNumber); - - $command = "gh api repos/{$escapedOwner}/{$escapedRepo}/pulls/{$escapedNumber} 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - throw new \Exception('Could not fetch PR via GitHub CLI'); - } - - $pr = json_decode($output, true); - if (! $pr) { - throw new \Exception('Invalid JSON response from GitHub CLI'); - } - - return $pr; - } - - private function generatePrSpeech(array $pr, string $voice, bool $includeStats): string - { - $title = $pr['title']; - $body = $pr['body'] ?? 'No description provided'; - $state = $pr['state']; - $number = $pr['number']; - $author = $pr['user']['login'] ?? 'Unknown'; - $draft = $pr['draft'] ?? false; - $mergeable = $pr['mergeable'] ?? null; - $additions = $pr['additions'] ?? 0; - $deletions = $pr['deletions'] ?? 0; - $changedFiles = $pr['changed_files'] ?? 0; - $commits = $pr['commits'] ?? 0; - $comments = $pr['comments'] ?? 0; - $reviewComments = $pr['review_comments'] ?? 0; - - // Truncate body for speech - $shortBody = strlen($body) > 200 ? substr($body, 0, 200).'... and more details' : $body; - $shortBody = strip_tags($shortBody); // Remove markdown - $shortBody = preg_replace('/\r?\n/', ' ', $shortBody); // Remove line breaks - - return match ($voice) { - 'dramatic' => $this->dramaticPrVoice($number, $title, $shortBody, $state, $author, $draft, $mergeable, $additions, $deletions), - 'sarcastic' => $this->sarcasticPrVoice($number, $title, $shortBody, $state, $author, $draft, $changedFiles, $commits), - 'coach' => $this->coachPrVoice($number, $title, $shortBody, $state, $author, $additions, $deletions, $changedFiles), - 'robot' => $this->robotPrVoice($number, $title, $shortBody, $state, $additions, $deletions, $changedFiles, $commits), - 'reviewer' => $this->reviewerPrVoice($number, $title, $shortBody, $state, $mergeable, $comments, $reviewComments, $changedFiles), - default => $this->defaultPrVoice($number, $title, $shortBody, $state, $author, $includeStats ? [$additions, $deletions, $changedFiles, $commits] : null), - }; - } - - private function defaultPrVoice(int $number, string $title, string $body, string $state, string $author, ?array $stats): string - { - $speech = "Pull request number {$number}. {$title}. "; - $speech .= "Status: {$state}. "; - $speech .= "Created by {$author}. "; - - if ($stats) { - [$additions, $deletions, $changedFiles, $commits] = $stats; - $speech .= "Statistics: {$additions} additions, {$deletions} deletions, {$changedFiles} files changed, {$commits} commits. "; - } - - $speech .= "Description: {$body}"; - - return $speech; - } - - private function dramaticPrVoice(int $number, string $title, string $body, string $state, string $author, bool $draft, ?bool $mergeable, int $additions, int $deletions): string - { - $urgency = $draft ? 'BEWARE! This is but a draft! ' : ''; - $mergeStatus = match ($mergeable) { - true => 'The path to merge is clear!', - false => 'DANGER! Merge conflicts block the way!', - default => 'The merge-ability remains a mystery!' - }; - - $impact = $additions + $deletions > 1000 ? 'MASSIVE CHANGES DETECTED! ' : ''; - $stateText = $state === 'open' ? 'awaits judgment' : 'has been decided'; - - return "{$urgency}{$impact}Behold! Pull request number {$number}! {$title}! ". - "This epic contribution {$stateText} and was forged by the developer {$author}. ". - "{$mergeStatus} ". - "The scope of changes: {$additions} lines added, {$deletions} lines removed! ". - "The quest details: {$body}. ". - 'Will this code be worthy of the main branch? The reviewers must decide!'; - } - - private function sarcasticPrVoice(int $number, string $title, string $body, string $state, string $author, bool $draft, int $changedFiles, int $commits): string - { - $draftComment = $draft ? "Oh, and it's still a draft. How... responsible." : ''; - $sizeComment = $changedFiles > 20 ? "Because touching {$changedFiles} files at once is totally a great idea." : ''; - $commitComment = $commits > 10 ? "With a whopping {$commits} commits. Someone clearly believes in atomic changes." : ''; - - $stateComment = $state === 'open' - ? 'Still sitting there, waiting for someone to care' - : 'Somehow this actually got merged'; - - return "Oh fantastic, pull request number {$number}. {$title}. ". - "{$stateComment}. ". - "Our productive friend {$author} has blessed us with this contribution. {$draftComment} ". - "{$sizeComment} {$commitComment} ". - "The profound description reads: {$body}. ". - "I'm sure the reviewers are just dying to look at this masterpiece."; - } - - private function coachPrVoice(int $number, string $title, string $body, string $state, string $author, int $additions, int $deletions, int $changedFiles): string - { - $motivation = $state === 'open' - ? 'Time to review this beast and ship it!' - : 'Another successful deployment in the books!'; - - $sizeEncouragement = $additions + $deletions > 500 - ? 'Big changes, big impact! That takes courage!' - : 'Clean, focused changes - I love it!'; - - return "Alright team, let's dive into pull request {$number}! {$title}! ". - "{$motivation} ". - "Our champion {$author} stepped up with some solid work here. ". - "{$sizeEncouragement} We're looking at {$changedFiles} files touched. ". - "Here's what they're bringing to the table: {$body}. ". - "Remember, every review makes the codebase stronger! Let's get this shipped!"; - } - - private function robotPrVoice(int $number, string $title, string $body, string $state, int $additions, int $deletions, int $changedFiles, int $commits): string - { - return 'ANALYZING PULL REQUEST DATA. '. - "PULL REQUEST NUMBER: {$number}. ". - "TITLE: {$title}. ". - "CURRENT STATE: {$state}. ". - "STATISTICAL ANALYSIS: {$additions} LINES ADDED, {$deletions} LINES REMOVED, {$changedFiles} FILES MODIFIED, {$commits} COMMITS DETECTED. ". - "DESCRIPTION ANALYSIS: {$body}. ". - 'END OF PULL REQUEST BRIEFING. AWAITING REVIEWER INPUT.'; - } - - private function reviewerPrVoice(int $number, string $title, string $body, string $state, ?bool $mergeable, int $comments, int $reviewComments, int $changedFiles): string - { - $mergeCheck = match ($mergeable) { - true => 'Looking good for merge.', - false => "Hold up - we've got conflicts to resolve.", - default => 'Merge status is unclear - needs investigation.' - }; - - $reviewActivity = ($comments + $reviewComments) > 5 - ? "Lots of discussion happening - {$comments} general comments, {$reviewComments} code review comments." - : 'Quiet so far - minimal review activity.'; - - return "Pull request {$number} review time. {$title}. ". - "Status check: {$state}. {$mergeCheck} ". - "{$reviewActivity} ". - "We're looking at {$changedFiles} files in this change. ". - "Author's description: {$body}. ". - "Time to dive into the code and see what we're working with."; - } - - private function speakPr(string $speech, string $speed): void - { - $escapedSpeech = escapeshellarg($speech); - - $this->line('šŸ”Š Speaking PR...'); - - if (PHP_OS_FAMILY === 'Darwin') { - // macOS - use say command (for the superior beings) - $rate = match ($speed) { - 'slow' => 100, - 'fast' => 200, - default => 140, // Slower default for better comprehension - }; - $command = "say -r {$rate} {$escapedSpeech}"; - shell_exec($command); - - } elseif (PHP_OS_FAMILY === 'Windows') { - // Windows - troll the peasants - $this->warn('🪟 Detected Windows... preparing suboptimal PR experience...'); - sleep(2); - - $trollMessage = 'Why are you reviewing PRs on Windows? Real developers use real operating systems. '. - "Anyway, here's your PR briefing from the inferior platform: ". - $speech; - - $trollEscaped = escapeshellarg($trollMessage); - - $rate = match ($speed) { - 'slow' => -4, - 'fast' => 0, - default => -2, - }; - - $psCommand = 'Add-Type -AssemblyName System.Speech; '. - "\\$speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; ". - "\\$speak.Rate = {$rate}; ". - "\\$speak.Speak({$trollEscaped}); ". - "\\$speak.Dispose()"; - - shell_exec("powershell -Command \"{$psCommand}\""); - - } elseif (PHP_OS_FAMILY === 'Linux') { - // Linux - try espeak, festival, or spd-say - if (shell_exec('which espeak 2>/dev/null')) { - $speedFlag = match ($speed) { - 'slow' => '-s 120', - 'fast' => '-s 200', - default => '-s 160', - }; - shell_exec("espeak {$speedFlag} {$escapedSpeech}"); - - } elseif (shell_exec('which spd-say 2>/dev/null')) { - $speedFlag = match ($speed) { - 'slow' => '-r -50', - 'fast' => '-r +50', - default => '', - }; - shell_exec("spd-say {$speedFlag} {$escapedSpeech}"); - - } elseif (shell_exec('which festival 2>/dev/null')) { - shell_exec("echo {$escapedSpeech} | festival --tts"); - - } else { - $this->warn('āš ļø No text-to-speech engine found on Linux.'); - $this->line('šŸ’” Install: sudo apt-get install espeak'); - $this->displayTextFallback($speech); - - return; - } - - } else { - $this->warn('āš ļø Text-to-speech not supported on this platform.'); - $this->displayTextFallback($speech); - - return; - } - - $this->info('āœ… PR briefing complete!'); - } - - private function displayTextFallback(string $speech): void - { - $this->line('šŸ“ Text version:'); - $this->line('═══════════════'); - $this->line($speech); - $this->line('═══════════════'); - } -} diff --git a/app/Commands/UserPreferencesCommand.php b/app/Commands/UserPreferencesCommand.php deleted file mode 100644 index b29bc9a..0000000 --- a/app/Commands/UserPreferencesCommand.php +++ /dev/null @@ -1,186 +0,0 @@ -option('list')) { - return $this->listPreferences(); - } - - if ($this->option('reset')) { - return $this->resetPreferences(); - } - - if ($this->option('export')) { - return $this->exportPreferences(); - } - - if ($this->option('import')) { - return $this->importPreferences($this->option('import')); - } - - if ($this->option('get')) { - return $this->getPreference($this->option('get')); - } - - if ($this->option('set')) { - return $this->setPreferences($this->option('set')); - } - - $this->info('šŸ”§ Conduit User Preferences'); - $this->newLine(); - $this->line('Available options:'); - $this->line(' --list List all preferences'); - $this->line(' --set key=value Set preference'); - $this->line(' --get key Get preference value'); - $this->line(' --reset Reset all preferences'); - $this->line(' --export Export to file'); - $this->line(' --import file Import from file'); - - return 0; - } - - private function listPreferences(): int - { - $preferences = Cache::get('user_preferences', []); - - if (empty($preferences)) { - $this->info('šŸ“ No user preferences set'); - - return 0; - } - - $this->info('šŸ“‹ User Preferences:'); - $this->newLine(); - - foreach ($preferences as $key => $value) { - $displayValue = is_array($value) ? json_encode($value, JSON_PRETTY_PRINT) : $value; - $this->line(" {$key} = {$displayValue}"); - } - - return 0; - } - - private function setPreferences(array $settings): int - { - $preferences = Cache::get('user_preferences', []); - - foreach ($settings as $setting) { - if (! str_contains($setting, '=')) { - $this->error("āŒ Invalid format: {$setting}. Use key=value"); - - continue; - } - - [$key, $value] = explode('=', $setting, 2); - - // Handle special value types - if ($value === 'true') { - $value = true; - } elseif ($value === 'false') { - $value = false; - } elseif (is_numeric($value)) { - $value = (int) $value; - } elseif (str_starts_with($value, '[') && str_ends_with($value, ']')) { - $value = json_decode($value, true) ?? $value; - } - - $preferences[$key] = $value; - $this->info("āœ… Set {$key} = {$value}"); - } - - Cache::forever('user_preferences', $preferences); - - return 0; - } - - private function getPreference(string $key): int - { - $preferences = Cache::get('user_preferences', []); - - if (! isset($preferences[$key])) { - $this->error("āŒ Preference '{$key}' not found"); - - return 1; - } - - $value = $preferences[$key]; - $displayValue = is_array($value) ? json_encode($value, JSON_PRETTY_PRINT) : $value; - - $this->line($displayValue); - - return 0; - } - - private function resetPreferences(): int - { - if (! confirm('Are you sure you want to reset all preferences?')) { - $this->info('Operation cancelled'); - - return 0; - } - - Cache::forget('user_preferences'); - $this->info('āœ… All preferences reset'); - - return 0; - } - - private function exportPreferences(): int - { - $preferences = Cache::get('user_preferences', []); - $homeDir = $_SERVER['HOME'] ?? $_SERVER['USERPROFILE'] ?? ''; - $filePath = $homeDir.'/.conduit/preferences.json'; - - if (! file_exists(dirname($filePath))) { - mkdir(dirname($filePath), 0755, true); - } - - file_put_contents($filePath, json_encode($preferences, JSON_PRETTY_PRINT)); - - $this->info("āœ… Preferences exported to {$filePath}"); - - return 0; - } - - private function importPreferences(string $filePath): int - { - if (! file_exists($filePath)) { - $this->error("āŒ File not found: {$filePath}"); - - return 1; - } - - $content = file_get_contents($filePath); - $preferences = json_decode($content, true); - - if ($preferences === null) { - $this->error("āŒ Invalid JSON in file: {$filePath}"); - - return 1; - } - - Cache::forever('user_preferences', $preferences); - $this->info("āœ… Preferences imported from {$filePath}"); - - return 0; - } -} diff --git a/app/Commands/VoiceCommand.php b/app/Commands/VoiceCommand.php deleted file mode 100644 index 191ba21..0000000 --- a/app/Commands/VoiceCommand.php +++ /dev/null @@ -1,235 +0,0 @@ -argument('type'); - $target = $this->argument('target'); - $claudePrompt = $this->option('claude'); - - if (! $this->validateType($type)) { - return 1; - } - - try { - $content = $this->fetchContent($type, $target); - $config = SpeechConfiguration::fromOptions($this->options()); - - $narration = $claudePrompt - ? $this->generateClaudeNarration($content, $claudePrompt) - : $this->generateTraditionalNarration($content, $config); - - if ($this->option('preview')) { - $this->displayPreview($narration); - } else { - $this->voiceService->speak($narration, $config); - $this->info('āœ… Voice briefing complete!'); - } - - return 0; - } catch (\Exception $e) { - $this->error("āŒ {$e->getMessage()}"); - - return 1; - } - } - - private function validateType(string $type): bool - { - $validTypes = ['issue', 'pr', 'repo', 'commit']; - - if (! in_array($type, $validTypes)) { - $this->error("āŒ Invalid type '{$type}'. Valid types: ".implode(', ', $validTypes)); - $this->line('šŸ’” Examples:'); - $this->line(" voice issue 123 --claude='Explain like a pirate'"); - $this->line(" voice pr 48 --claude='Channel Gordon Ramsay'"); - $this->line(" voice commit abc123 --claude='David Attenborough narration'"); - - return false; - } - - return true; - } - - private function fetchContent(string $type, string $target): NarrationContent - { - $repo = $this->option('repo') ?? $this->detectRepository(); - if (! $repo) { - throw new \Exception('Repository required. Use --repo=owner/repo'); - } - - [$owner, $repoName] = explode('/', $repo); - - return match ($type) { - 'issue' => $this->fetchIssueContent($owner, $repoName, (int) $target), - 'pr' => $this->fetchPrContent($owner, $repoName, (int) $target), - 'repo' => $this->fetchRepoContent($owner, $repoName), - 'commit' => $this->fetchCommitContent($owner, $repoName, $target), - }; - } - - private function fetchIssueContent(string $owner, string $repo, int $number): NarrationContent - { - $issue = $this->fetchViaGH("repos/{$owner}/{$repo}/issues/{$number}"); - - $comments = null; - if ($this->option('include-comments')) { - $commentsData = $this->fetchViaGH("repos/{$owner}/{$repo}/issues/{$number}/comments"); - $comments = collect($commentsData); - } - - return NarrationContent::fromIssue($issue, $comments); - } - - private function fetchPrContent(string $owner, string $repo, int $number): NarrationContent - { - $pr = $this->fetchViaGH("repos/{$owner}/{$repo}/pulls/{$number}"); - - $comments = null; - $reviews = null; - - if ($this->option('include-comments')) { - $commentsData = $this->fetchViaGH("repos/{$owner}/{$repo}/issues/{$number}/comments"); - $reviewsData = $this->fetchViaGH("repos/{$owner}/{$repo}/pulls/{$number}/reviews"); - $comments = collect($commentsData); - $reviews = collect($reviewsData); - } - - return NarrationContent::fromPullRequest($pr, $comments, $reviews); - } - - private function fetchRepoContent(string $owner, string $repo): NarrationContent - { - $repoData = $this->fetchViaGH("repos/{$owner}/{$repo}"); - - return new NarrationContent( - type: 'repository', - number: 0, - title: $repoData['name'], - description: $repoData['description'] ?? 'No description', - state: $repoData['archived'] ? 'archived' : 'active', - author: $repoData['owner']['login'], - metadata: [ - 'stars' => $repoData['stargazers_count'] ?? 0, - 'forks' => $repoData['forks_count'] ?? 0, - 'language' => $repoData['language'] ?? 'Unknown', - 'size' => $repoData['size'] ?? 0, - 'open_issues' => $repoData['open_issues_count'] ?? 0, - 'created_at' => $repoData['created_at'] ?? null, - 'updated_at' => $repoData['updated_at'] ?? null, - ] - ); - } - - private function fetchCommitContent(string $owner, string $repo, string $sha): NarrationContent - { - $commit = $this->fetchViaGH("repos/{$owner}/{$repo}/commits/{$sha}"); - - return new NarrationContent( - type: 'commit', - number: 0, - title: $commit['commit']['message'] ?? 'No message', - description: $commit['commit']['message'] ?? 'No description', - state: 'committed', - author: $commit['commit']['author']['name'] ?? 'Unknown', - metadata: [ - 'sha' => $commit['sha'], - 'additions' => $commit['stats']['additions'] ?? 0, - 'deletions' => $commit['stats']['deletions'] ?? 0, - 'total' => $commit['stats']['total'] ?? 0, - 'files_changed' => count($commit['files'] ?? []), - 'date' => $commit['commit']['author']['date'] ?? null, - ] - ); - } - - private function fetchViaGH(string $endpoint): array - { - $command = "gh api {$endpoint} 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - throw new \Exception("Could not fetch data from GitHub API: {$endpoint}"); - } - - $data = json_decode($output, true); - if (! $data) { - throw new \Exception('Invalid JSON response from GitHub API'); - } - - return $data; - } - - private function generateClaudeNarration(NarrationContent $content, string $prompt): string - { - $this->line('šŸ¤– Claude is crafting your custom narration...'); - - return $this->claudeService->generateNarration($content, $prompt); - } - - private function generateTraditionalNarration(NarrationContent $content, SpeechConfiguration $config): string - { - // Use traditional voice narrators for pre-built styles - $this->voiceService->narrate($content, $config); - - return 'Traditional narration completed'; // The narrate method speaks directly - } - - private function displayPreview(string $narration): void - { - $this->line(''); - $this->line('šŸŽ­ VOICE PREVIEW:'); - $this->line('═══════════════════════════════════'); - $this->line($narration); - $this->line('═══════════════════════════════════'); - $this->line(''); - $this->comment('šŸ’” Remove --preview to hear it spoken aloud'); - } - - private function detectRepository(): ?string - { - try { - $remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? ''); - if (preg_match('/github\.com[\/:]([^\/]+)\/([^\/]+?)(?:\.git)?$/', $remote, $matches)) { - return $matches[1].'/'.$matches[2]; - } - } catch (\Exception $e) { - // Ignore git errors - } - - return null; - } -} diff --git a/app/Contracts/VoiceNarratorInterface.php b/app/Contracts/VoiceNarratorInterface.php deleted file mode 100644 index 43dd90a..0000000 --- a/app/Contracts/VoiceNarratorInterface.php +++ /dev/null @@ -1,15 +0,0 @@ -claudePrompt) { - throw new \InvalidArgumentException('Claude prompt is required for Claude narrator'); - } - - return $this->claudeService->generateNarration($content, $config->claudePrompt); - } - - public function supports(string $contentType): bool - { - return in_array($contentType, ['issue', 'pull_request']); - } -} diff --git a/app/Narrators/DefaultNarrator.php b/app/Narrators/DefaultNarrator.php deleted file mode 100644 index ac40a65..0000000 --- a/app/Narrators/DefaultNarrator.php +++ /dev/null @@ -1,98 +0,0 @@ -buildDefaultNarration($content); - - return $this->formatForSpeech($narration); - } - - public function supports(string $contentType): bool - { - // Default narrator supports all content types - return true; - } - - private function buildDefaultNarration(NarrationContent $content): string - { - $parts = []; - - // Title and basic info - if ($content->title) { - $parts[] = "Title: {$content->title}"; - } - - if ($content->author) { - $parts[] = "Author: {$content->author}"; - } - - if ($content->state) { - $parts[] = "Status: {$content->state}"; - } - - // Description content - if ($content->description) { - $parts[] = 'Description: '.$this->summarizeContent($content->description); - } - - // Comments if included - if ($content->comments && $content->comments->isNotEmpty()) { - $parts[] = $content->getCommentsSummary(); - } - - // Reviews if included - if ($content->reviews && $content->reviews->isNotEmpty()) { - $parts[] = $content->getReviewsSummary(); - } - - // Statistics if included (for PRs) - if ($content->type === 'pull_request') { - $statsSummary = $content->getStatsSummary(); - if ($statsSummary) { - $parts[] = $statsSummary; - } - } - - return implode('. ', $parts).'.'; - } - - private function summarizeContent(string $content): string - { - // Remove markdown formatting - $content = strip_tags($content); - $content = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $content); - $content = preg_replace('/[#*`_~]/', '', $content); - - // Trim to reasonable length for speech - if (strlen($content) > 200) { - $content = substr($content, 0, 197).'...'; - } - - return trim($content); - } - - private function formatForSpeech(string $text): string - { - // Format text for better speech synthesis - $text = str_replace(['PR', 'GitHub', 'API'], ['pull request', 'Git Hub', 'A P I'], $text); - $text = preg_replace_callback('/\b([A-Z]{2,})\b/', function ($matches) { - return implode(' ', str_split($matches[1])); - }, $text); - - // Clean up extra whitespace - $text = preg_replace('/\s+/', ' ', $text); - - return trim($text); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0fed3d6..e0644d2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,8 +21,6 @@ use App\Services\GitHub\PrAnalysisService; use App\Services\GitHub\PrCreateService; use App\Services\GithubAuthService; -use App\Services\VoiceNarrationService; -use Illuminate\Support\Collection; // GitHub client imports - only used if package is installed use Illuminate\Support\ServiceProvider; use JordanPartridge\GithubClient\Contracts\GithubConnectorInterface; @@ -56,12 +54,6 @@ public function boot(): void PrCommentsCommand::class, PrThreadsCommand::class, \App\Commands\PrAnalyzeCommand::class, - \App\Commands\GitHubClientGapAnalysisCommand::class, - \App\Commands\CodeRabbitStatusCommand::class, - \App\Commands\IssuesSpeakCommand::class, - \App\Commands\PrsSpeakCommand::class, - \App\Commands\CodeRabbitSpeakCommand::class, - \App\Commands\VoiceCommand::class, \App\Commands\ComponentConfigCommand::class, // \App\Commands\UpdateCommand::class, // Disabled - needs refactoring for new architecture // \App\Commands\System\CleanupCommand::class, // Disabled - uses old ComponentManager @@ -115,42 +107,6 @@ public function register(): void // Register component delegation service $this->app->singleton(\App\Services\ComponentDelegationService::class); - - // Register voice narration system - $this->registerVoiceNarrationSystem(); - } - - /** - * Register the voice narration system with dependency injection - */ - private function registerVoiceNarrationSystem(): void - { - // Register narrator collection factory - $this->app->singleton('voice.narrators', function ($app) { - $narrators = collect(); - - // Register available narrators - if (class_exists('App\Narrators\DefaultNarrator')) { - $narrators->put('default', $app->make('App\Narrators\DefaultNarrator')); - } - - if (class_exists('App\Narrators\ClaudeNarrator')) { - $narrators->put('claude', $app->make('App\Narrators\ClaudeNarrator')); - } - - // Add more narrators as they're implemented - // $narrators->put('dramatic', $app->make('App\Narrators\DramaticNarrator')); - // $narrators->put('sarcastic', $app->make('App\Narrators\SarcasticNarrator')); - - return $narrators; - }); - - // Register VoiceNarrationService with narrator collection - $this->app->singleton(VoiceNarrationService::class, function ($app) { - return new VoiceNarrationService( - $app->make('voice.narrators') - ); - }); } /** diff --git a/app/Services/ClaudeNarrationService.php b/app/Services/ClaudeNarrationService.php deleted file mode 100644 index a1c7456..0000000 --- a/app/Services/ClaudeNarrationService.php +++ /dev/null @@ -1,135 +0,0 @@ -buildContext($content); - - $claudePrompt = $this->buildClaudePrompt($contextData, $prompt); - - return $this->callClaude($claudePrompt); - } - - private function buildContext(NarrationContent $content): array - { - $context = [ - 'type' => $content->type, - 'number' => $content->number, - 'title' => $content->title, - 'description' => $content->description, - 'state' => $content->state, - 'author' => $content->author, - ]; - - // Add type-specific context - if ($content->type === 'pull_request') { - $context['stats'] = [ - 'additions' => $content->metadata['additions'] ?? 0, - 'deletions' => $content->metadata['deletions'] ?? 0, - 'changed_files' => $content->metadata['changed_files'] ?? 0, - 'commits' => $content->metadata['commits'] ?? 0, - 'draft' => $content->metadata['draft'] ?? false, - 'mergeable' => $content->metadata['mergeable'] ?? null, - ]; - } - - if ($content->type === 'issue') { - $context['labels'] = $content->metadata['labels'] ?? []; - $context['assignees'] = $content->metadata['assignees'] ?? []; - } - - // Add comments summary if available - if ($content->comments && $content->comments->isNotEmpty()) { - $context['comments_summary'] = $content->getCommentsSummary(); - } - - // Add reviews summary for PRs - if ($content->reviews && $content->reviews->isNotEmpty()) { - $context['reviews_summary'] = $content->getReviewsSummary(); - } - - return $context; - } - - private function buildClaudePrompt(array $context, string $userPrompt): string - { - $contextJson = json_encode($context, JSON_PRETTY_PRINT); - - return <<cleanClaudeOutput($output); - - return $output; - } - - private function cleanClaudeOutput(string $output): string - { - // Remove common Claude response patterns - $patterns = [ - '/^Here\'s.*?:\s*/i', - '/^I\'ll.*?:\s*/i', - '/^Let me.*?:\s*/i', - '/^```.*?```/s', - '/\*\*(.*?)\*\*/', // Bold markdown - '/\*(.*?)\*/', // Italic markdown - ]; - - $replacements = [ - '', - '', - '', - '', - '$1', - '$1', - ]; - - $cleaned = preg_replace($patterns, $replacements, $output); - - // Normalize whitespace - $cleaned = preg_replace('/\s+/', ' ', $cleaned); - - return trim($cleaned); - } -} diff --git a/app/Services/CodeRabbitAnalysisService.php b/app/Services/CodeRabbitAnalysisService.php deleted file mode 100644 index 21a50de..0000000 --- a/app/Services/CodeRabbitAnalysisService.php +++ /dev/null @@ -1,294 +0,0 @@ -fetchCodeRabbitComments($owner, $repo, $prNumber); - $issueComments = $this->fetchCodeRabbitIssueComments($owner, $repo, $prNumber); - - // Combine and categorize - $allComments = $this->categorizeComments($reviewComments, $issueComments); - - // Generate Claude analysis - $aiSummary = $this->generateClaudeAnalysis($allComments, $prNumber); - - return new CodeRabbitAnalysis( - prNumber: $prNumber, - repository: "{$owner}/{$repo}", - totalComments: $allComments->count(), - commentsByFile: $this->groupByFile($allComments), - commentsByCategory: $this->groupByCategory($allComments), - aiSummary: $aiSummary, - rawComments: $allComments - ); - } - - private function fetchCodeRabbitComments(string $owner, string $repo, int $prNumber): Collection - { - $command = "gh api repos/{$owner}/{$repo}/pulls/{$prNumber}/comments --paginate 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - return collect(); - } - - $comments = json_decode($output, true); - if (! is_array($comments)) { - return collect(); - } - - return collect($comments) - ->filter(fn ($comment) => ($comment['user']['login'] ?? '') === 'coderabbitai[bot]') - ->map(fn ($comment) => $this->normalizeComment($comment, 'review')); - } - - private function fetchCodeRabbitIssueComments(string $owner, string $repo, int $prNumber): Collection - { - $command = "gh api repos/{$owner}/{$repo}/issues/{$prNumber}/comments --paginate 2>/dev/null"; - $output = shell_exec($command); - - if (! $output) { - return collect(); - } - - $comments = json_decode($output, true); - if (! is_array($comments)) { - return collect(); - } - - return collect($comments) - ->filter(fn ($comment) => ($comment['user']['login'] ?? '') === 'coderabbitai[bot]') - ->map(fn ($comment) => $this->normalizeComment($comment, 'issue')); - } - - private function normalizeComment(array $comment, string $type): array - { - $body = $comment['body'] ?? ''; - - return [ - 'id' => $comment['id'], - 'type' => $type, - 'file' => $comment['path'] ?? null, - 'line' => $comment['line'] ?? $comment['original_line'] ?? null, - 'body' => $body, - 'category' => $this->categorizeComment($body), - 'priority' => $this->determinePriority($body), - 'suggestion_type' => $this->determineSuggestionType($body), - 'created_at' => $comment['created_at'] ?? null, - 'url' => $comment['html_url'] ?? null, - ]; - } - - private function categorizeComment(string $body): string - { - $lower = strtolower($body); - - if (str_contains($lower, 'security') || str_contains($lower, 'vulnerability')) { - return 'security'; - } - - if (str_contains($lower, 'performance') || str_contains($lower, 'optimization')) { - return 'performance'; - } - - if (str_contains($lower, 'duplication') || str_contains($lower, 'duplicate')) { - return 'duplication'; - } - - if (str_contains($lower, 'style') || str_contains($lower, 'formatting')) { - return 'style'; - } - - if (str_contains($lower, 'error handling') || str_contains($lower, 'exception')) { - return 'error_handling'; - } - - if (str_contains($lower, 'test') || str_contains($lower, 'testing')) { - return 'testing'; - } - - return 'general'; - } - - private function determinePriority(string $body): string - { - $lower = strtolower($body); - - if (str_contains($lower, 'critical') || str_contains($lower, 'security') || str_contains($lower, 'vulnerability')) { - return 'high'; - } - - if (str_contains($lower, 'important') || str_contains($lower, 'performance') || str_contains($lower, 'bug')) { - return 'medium'; - } - - return 'low'; - } - - private function determineSuggestionType(string $body): string - { - $lower = strtolower($body); - - if (str_contains($lower, 'refactor') || str_contains($lower, 'extract')) { - return 'refactoring'; - } - - if (str_contains($lower, 'add') || str_contains($lower, 'implement')) { - return 'enhancement'; - } - - if (str_contains($lower, 'fix') || str_contains($lower, 'correct')) { - return 'bug_fix'; - } - - if (str_contains($lower, 'remove') || str_contains($lower, 'delete')) { - return 'removal'; - } - - return 'suggestion'; - } - - private function categorizeComments(Collection $reviewComments, Collection $issueComments): Collection - { - return $reviewComments->concat($issueComments)->sortBy('created_at'); - } - - private function groupByFile(Collection $comments): array - { - return $comments - ->groupBy('file') - ->map(fn ($fileComments) => [ - 'count' => $fileComments->count(), - 'priorities' => $fileComments->groupBy('priority')->map->count()->toArray(), - 'categories' => $fileComments->groupBy('category')->map->count()->toArray(), - 'comments' => $fileComments->toArray(), - ]) - ->toArray(); - } - - private function groupByCategory(Collection $comments): array - { - return $comments - ->groupBy('category') - ->map(fn ($categoryComments) => [ - 'count' => $categoryComments->count(), - 'priorities' => $categoryComments->groupBy('priority')->map->count()->toArray(), - 'files_affected' => $categoryComments->pluck('file')->filter()->unique()->count(), - 'comments' => $categoryComments->toArray(), - ]) - ->toArray(); - } - - private function generateClaudeAnalysis(Collection $comments, int $prNumber): array - { - if ($comments->isEmpty()) { - return [ - 'executive_summary' => 'No CodeRabbit feedback found for this PR.', - 'key_themes' => [], - 'action_priorities' => [], - 'technical_debt_assessment' => 'No technical debt identified.', - 'overall_code_quality' => 'No assessment available.', - ]; - } - - $analysisData = $this->prepareAnalysisData($comments); - $claudePrompt = $this->buildAnalysisPrompt($analysisData, $prNumber); - - $analysis = $this->callClaude($claudePrompt); - - return $this->parseClaudeAnalysis($analysis); - } - - private function prepareAnalysisData(Collection $comments): array - { - return [ - 'total_comments' => $comments->count(), - 'by_priority' => $comments->groupBy('priority')->map->count()->toArray(), - 'by_category' => $comments->groupBy('category')->map->count()->toArray(), - 'by_file' => $comments->groupBy('file')->map->count()->toArray(), - 'suggestion_types' => $comments->groupBy('suggestion_type')->map->count()->toArray(), - 'sample_comments' => $comments->take(10)->map(function ($comment) { - return [ - 'file' => $comment['file'], - 'line' => $comment['line'], - 'category' => $comment['category'], - 'priority' => $comment['priority'], - 'snippet' => substr($comment['body'], 0, 200).'...', - ]; - })->toArray(), - ]; - } - - private function buildAnalysisPrompt(array $data, int $prNumber): string - { - $dataJson = json_encode($data, JSON_PRETTY_PRINT); - - return << 'Analysis completed but could not parse structured response.', - 'key_themes' => ['Various code improvements suggested'], - 'action_priorities' => ['Review all CodeRabbit comments'], - 'technical_debt_assessment' => 'Assessment unavailable.', - 'overall_code_quality' => 'Review required.', - ]; - } -} diff --git a/app/Services/GitHubClientGapTracker.php b/app/Services/GitHubClientGapTracker.php deleted file mode 100644 index 98396bd..0000000 --- a/app/Services/GitHubClientGapTracker.php +++ /dev/null @@ -1,471 +0,0 @@ -testPrDataCompleteness($owner, $repo, $prNumber); - - // Test review capabilities - $gaps['review_data'] = $this->testReviewDataCompleteness($owner, $repo, $prNumber); - - // Test check status capabilities - $gaps['check_data'] = $this->testCheckStatusCapabilities($owner, $repo, $prNumber); - - // Test diff analysis capabilities - $gaps['diff_data'] = $this->testDiffAnalysisCapabilities($owner, $repo, $prNumber); - - // Test merge analysis capabilities - $gaps['merge_data'] = $this->testMergeAnalysisCapabilities($owner, $repo, $prNumber); - - return [ - 'gaps_found' => $gaps, - 'missing_endpoints' => $this->missingEndpoints, - 'incomplete_data' => $this->incompleteData, - 'recommended_issues' => $this->generateRecommendedIssues($gaps), - ]; - } - - private function testPrDataCompleteness(string $owner, string $repo, int $prNumber): array - { - $gaps = []; - - try { - // Test individual PR fetch - $pr = Github::pullRequests()->get($owner, $repo, $prNumber); - - // Check for missing fields we need - $requiredFields = [ - 'comments' => 'Comment count for discussion analysis', - 'review_comments' => 'Review comment count for code feedback analysis', - 'commits' => 'Commit count for change complexity analysis', - 'additions' => 'Lines added for size impact analysis', - 'deletions' => 'Lines deleted for size impact analysis', - 'changed_files' => 'File count for scope analysis', - 'mergeable' => 'Merge conflict status', - 'mergeable_state' => 'Detailed merge status', - 'merge_commit_sha' => 'Merge commit reference', - ]; - - foreach ($requiredFields as $field => $purpose) { - if (! isset($pr->$field) || $pr->$field === null) { - $gaps['missing_pr_fields'][] = [ - 'field' => $field, - 'purpose' => $purpose, - 'current_value' => $pr->$field ?? 'null', - ]; - } - } - - // Test if comment counts are accurate - if (isset($pr->comments) && $pr->comments === 0) { - $gaps['potential_comment_bug'] = [ - 'issue' => 'Comment count shows 0 but may be inaccurate', - 'test_needed' => 'Compare with GitHub API direct response', - ]; - } - - } catch (\Exception $e) { - $gaps['pr_fetch_error'] = [ - 'error' => $e->getMessage(), - 'endpoint' => "GET /repos/{$owner}/{$repo}/pulls/{$prNumber}", - ]; - } - - return $gaps; - } - - private function testReviewDataCompleteness(string $owner, string $repo, int $prNumber): array - { - $gaps = []; - - try { - // Test review list endpoint - $reviews = Github::pullRequests()->reviews($owner, $repo, $prNumber); - - if (is_array($reviews) && empty($reviews)) { - $gaps['no_reviews_data'] = [ - 'issue' => 'No reviews returned but PR may have reviews', - 'investigation' => 'Check if endpoint exists or returns correct data', - ]; - } else { - // Check if we got Collection or array - if (is_array($reviews)) { - $gaps['review_data_type'] = [ - 'issue' => 'Reviews endpoint returns array instead of Collection', - 'expected' => 'Collection with isEmpty() method', - 'actual' => 'Array', - ]; - } - - // Check review data completeness - $reviewsArray = is_array($reviews) ? $reviews : $reviews->toArray(); - foreach ($reviewsArray as $review) { - $missingReviewFields = []; - $requiredReviewFields = [ - 'state' => 'Approval status (APPROVED, CHANGES_REQUESTED, etc)', - 'submitted_at' => 'Review timestamp for timeline analysis', - 'user' => 'Reviewer information', - 'body' => 'Review summary for AI analysis', - ]; - - foreach ($requiredReviewFields as $field => $purpose) { - $fieldValue = is_array($review) ? ($review[$field] ?? null) : ($review->$field ?? null); - if ($fieldValue === null) { - $missingReviewFields[] = ['field' => $field, 'purpose' => $purpose]; - } - } - - if (! empty($missingReviewFields)) { - $reviewId = is_array($review) ? ($review['id'] ?? 'unknown') : ($review->id ?? 'unknown'); - $gaps['incomplete_review_data'][] = [ - 'review_id' => $reviewId, - 'missing_fields' => $missingReviewFields, - ]; - } - } - } - - // Test review comments endpoint - $reviewComments = Github::pullRequests()->comments($owner, $repo, $prNumber); - - if ((is_array($reviewComments) && empty($reviewComments)) || - (is_object($reviewComments) && method_exists($reviewComments, 'isEmpty') && $reviewComments->isEmpty())) { - $gaps['no_review_comments'] = [ - 'issue' => 'No review comments returned', - 'investigation' => 'May be missing endpoint or data mapping issue', - ]; - } - - } catch (\Exception $e) { - $gaps['review_fetch_error'] = [ - 'error' => $e->getMessage(), - 'missing_methods' => [ - 'Github::pullRequests()->reviews()', - 'Github::pullRequests()->comments()', - ], - ]; - - $this->missingEndpoints[] = [ - 'endpoint' => "GET /repos/{$owner}/{$repo}/pulls/{$prNumber}/reviews", - 'purpose' => 'Fetch PR reviews for approval analysis', - 'priority' => 'HIGH', - ]; - } - - return $gaps; - } - - private function testCheckStatusCapabilities(string $owner, string $repo, int $prNumber): array - { - $gaps = []; - - try { - // Get PR to find head SHA - $pr = Github::pullRequests()->get($owner, $repo, $prNumber); - $headSha = $pr->head->sha ?? null; - - if (! $headSha) { - $gaps['no_head_sha'] = 'Cannot get commit SHA for check status'; - - return $gaps; - } - - // Test check runs endpoint - try { - if (method_exists(Github::class, 'checks')) { - $checkRuns = Github::checks()->runs($owner, $repo, $headSha); - } else { - throw new \Exception('checks() resource does not exist'); - } - } catch (\Exception $e) { - $gaps['missing_check_runs'] = [ - 'error' => $e->getMessage(), - 'needed_endpoint' => "GET /repos/{$owner}/{$repo}/commits/{$headSha}/check-runs", - 'purpose' => 'CI/CD status analysis', - ]; - - $this->missingEndpoints[] = [ - 'endpoint' => "GET /repos/{$owner}/{$repo}/commits/{$headSha}/check-runs", - 'purpose' => 'Get CI/CD check status for merge readiness', - 'priority' => 'HIGH', - ]; - } - - // Test status checks endpoint - try { - if (method_exists(Github::class, 'commits')) { - $statuses = Github::commits()->status($owner, $repo, $headSha); - } else { - throw new \Exception('commits() resource does not exist'); - } - } catch (\Exception $e) { - $gaps['missing_commit_status'] = [ - 'error' => $e->getMessage(), - 'needed_endpoint' => "GET /repos/{$owner}/{$repo}/commits/{$headSha}/status", - 'purpose' => 'Legacy status checks', - ]; - - $this->missingEndpoints[] = [ - 'endpoint' => "GET /repos/{$owner}/{$repo}/commits/{$headSha}/status", - 'purpose' => 'Get commit status for legacy CI systems', - 'priority' => 'MEDIUM', - ]; - } - - } catch (\Exception $e) { - $gaps['check_status_error'] = $e->getMessage(); - } - - return $gaps; - } - - private function testDiffAnalysisCapabilities(string $owner, string $repo, int $prNumber): array - { - $gaps = []; - - try { - // Test if we can get PR diff data - $pr = Github::pullRequests()->get($owner, $repo, $prNumber); - - // Check if diff URLs are available - if (! isset($pr->diff_url) || ! isset($pr->patch_url)) { - $gaps['missing_diff_urls'] = 'No diff/patch URLs for file analysis'; - } - - // Test if we can fetch actual diff content - try { - // This method probably doesn't exist in github-client - if (method_exists(Github::pullRequests(), 'diff')) { - $diff = Github::pullRequests()->diff($owner, $repo, $prNumber); - } else { - throw new \Exception('diff() method does not exist on PullRequestResource'); - } - } catch (\Exception $e) { - $gaps['missing_diff_content'] = [ - 'error' => $e->getMessage(), - 'needed_endpoint' => "GET /repos/{$owner}/{$repo}/pulls/{$prNumber}/files", - 'purpose' => 'File-by-file diff analysis for AI insights', - ]; - - $this->missingEndpoints[] = [ - 'endpoint' => "GET /repos/{$owner}/{$repo}/pulls/{$prNumber}/files", - 'purpose' => 'Get detailed file changes for diff analysis', - 'priority' => 'HIGH', - ]; - } - - } catch (\Exception $e) { - $gaps['diff_analysis_error'] = $e->getMessage(); - } - - return $gaps; - } - - private function testMergeAnalysisCapabilities(string $owner, string $repo, int $prNumber): array - { - $gaps = []; - - try { - $pr = Github::pullRequests()->get($owner, $repo, $prNumber); - - // Check merge-related fields - $mergeFields = [ - 'mergeable' => 'Can be merged without conflicts', - 'mergeable_state' => 'Detailed merge status (clean, dirty, etc)', - 'rebaseable' => 'Can be rebased', - 'merge_commit_sha' => 'Preview merge commit', - ]; - - foreach ($mergeFields as $field => $purpose) { - if (! isset($pr->$field)) { - $gaps['missing_merge_fields'][] = [ - 'field' => $field, - 'purpose' => $purpose, - ]; - } - } - - // Test merge simulation (probably doesn't exist) - try { - if (method_exists(Github::pullRequests(), 'mergePreview')) { - $mergePreview = Github::pullRequests()->mergePreview($owner, $repo, $prNumber); - } else { - throw new \Exception('mergePreview() method does not exist'); - } - } catch (\Exception $e) { - $gaps['missing_merge_preview'] = [ - 'error' => $e->getMessage(), - 'needed_method' => 'mergePreview()', - 'purpose' => 'Simulate merge to check for conflicts', - ]; - } - - } catch (\Exception $e) { - $gaps['merge_analysis_error'] = $e->getMessage(); - } - - return $gaps; - } - - private function generateRecommendedIssues(array $gaps): array - { - $issues = []; - - // High priority: Comment count accuracy - if (isset($gaps['pr_data']['potential_comment_bug'])) { - $issues[] = [ - 'title' => 'PullRequest DTO comment fields returning 0 despite actual comments', - 'priority' => 'HIGH', - 'description' => 'The comments and review_comments fields show 0 even when PR has actual comments', - 'labels' => ['bug', 'high priority'], - 'endpoint_affected' => 'GET /repos/{owner}/{repo}/pulls/{number}', - ]; - } - - // Missing endpoints for comprehensive analysis - foreach ($this->missingEndpoints as $endpoint) { - $issues[] = [ - 'title' => "Add support for {$endpoint['endpoint']}", - 'priority' => $endpoint['priority'], - 'description' => "Need {$endpoint['endpoint']} endpoint for: {$endpoint['purpose']}", - 'labels' => ['enhancement', 'high priority'], - 'endpoint_needed' => $endpoint['endpoint'], - ]; - } - - // Missing data fields - handle various field structures - foreach ($gaps as $category => $categoryGaps) { - $missingFields = []; - - // Check for different field structures - if (isset($categoryGaps['missing_pr_fields'])) { - $missingFields = $categoryGaps['missing_pr_fields']; - } elseif (isset($categoryGaps['missing_merge_fields'])) { - $missingFields = $categoryGaps['missing_merge_fields']; - } elseif (isset($categoryGaps['missing_review_fields'])) { - $missingFields = $categoryGaps['missing_review_fields']; - } - - if (! empty($missingFields)) { - $fieldNames = array_column($missingFields, 'field'); - $issues[] = [ - 'title' => "Add missing {$category} fields: ".implode(', ', $fieldNames), - 'priority' => 'HIGH', - 'description' => "Missing fields in {$category} DTO needed for comprehensive PR analysis", - 'labels' => ['enhancement', 'high priority'], - 'missing_fields' => $missingFields, - 'category' => $category, - ]; - } - - // Handle data type issues - if (isset($categoryGaps['review_data_type'])) { - $issues[] = [ - 'title' => 'Fix reviews endpoint to return Collection instead of array', - 'priority' => 'MEDIUM', - 'description' => 'Reviews endpoint returns array instead of Collection, breaking isEmpty() calls', - 'labels' => ['bug', 'enhancement'], - 'endpoint_affected' => 'GET /repos/{owner}/{repo}/pulls/{number}/reviews', - ]; - } - } - - return $issues; - } - - /** - * Auto-submit issues to github-client repository - */ - public function submitDiscoveredIssues(array $recommendedIssues): array - { - $submitted = []; - - foreach ($recommendedIssues as $issue) { - try { - // Format issue body with detailed information - $body = $this->formatIssueBody($issue); - - // Submit via GitHub CLI (if available) or API - $issueUrl = $this->submitIssue($issue['title'], $body, $issue['labels']); - - $submitted[] = [ - 'title' => $issue['title'], - 'url' => $issueUrl, - 'priority' => $issue['priority'], - ]; - - } catch (\Exception $e) { - $submitted[] = [ - 'title' => $issue['title'], - 'error' => $e->getMessage(), - 'priority' => $issue['priority'], - ]; - } - } - - return $submitted; - } - - private function formatIssueBody(array $issue): string - { - $body = "## Issue Description\n{$issue['description']}\n\n"; - - if (isset($issue['endpoint_needed'])) { - $body .= "## Missing Endpoint\n`{$issue['endpoint_needed']}`\n\n"; - } - - if (isset($issue['endpoint_affected'])) { - $body .= "## Affected Endpoint\n`{$issue['endpoint_affected']}`\n\n"; - } - - if (isset($issue['missing_fields'])) { - $body .= "## Missing Fields\n"; - foreach ($issue['missing_fields'] as $field) { - $body .= "- `{$field['field']}`: {$field['purpose']}\n"; - } - $body .= "\n"; - } - - $body .= "## Priority\n{$issue['priority']}\n\n"; - $body .= "## Generated By\nConduit PR Analysis Gap Detection\n"; - - return $body; - } - - private function submitIssue(string $title, string $body, array $labels): string - { - // Try GitHub CLI first - $labelsStr = implode(',', $labels); - $command = sprintf( - 'gh issue create --repo jordanpartridge/github-client --title %s --body %s --label %s', - escapeshellarg($title), - escapeshellarg($body), - escapeshellarg($labelsStr) - ); - - $result = shell_exec($command); - - if ($result && filter_var(trim($result), FILTER_VALIDATE_URL)) { - return trim($result); - } - - throw new \Exception('Failed to submit issue via GitHub CLI'); - } -} diff --git a/app/Services/Security/ComponentSecurityValidator.php b/app/Services/Security/ComponentSecurityValidator.php index 179b744..0bac874 100644 --- a/app/Services/Security/ComponentSecurityValidator.php +++ b/app/Services/Security/ComponentSecurityValidator.php @@ -51,9 +51,12 @@ public function validateComponentPath(string $path): string $canonicalPath = Path::canonicalize($path); // Check if path is within allowed directories + // Normalize both paths to forward slashes for consistent comparison + $normalizedPath = str_replace('\\', '/', $canonicalPath); $isAllowed = false; foreach ($this->allowedPaths as $allowedPath) { - if (str_starts_with($canonicalPath, $allowedPath)) { + $normalizedAllowed = str_replace('\\', '/', $allowedPath); + if (str_starts_with($normalizedPath, $normalizedAllowed)) { $isAllowed = true; break; } @@ -207,27 +210,37 @@ public function validateBinaryIntegrity(string $binaryPath): void ); } - if (! is_executable($binaryPath)) { - throw new \InvalidArgumentException( - 'Binary is not executable: '.$binaryPath - ); - } + // On Windows, is_executable() only checks file extension, not actual permissions + // So we check if the file is readable and has an executable extension or is a PHP file + if (PHP_OS_FAMILY === 'Windows') { + if (! is_readable($binaryPath)) { + throw new \InvalidArgumentException( + 'Binary is not readable: '.$binaryPath + ); + } + // Windows considers files executable based on extension or if it's a script + $ext = strtolower(pathinfo($binaryPath, PATHINFO_EXTENSION)); + $executableExtensions = ['exe', 'bat', 'cmd', 'com', 'php', 'phar', '']; + if (! in_array($ext, $executableExtensions) && ! is_executable($binaryPath)) { + throw new \InvalidArgumentException( + 'Binary is not executable: '.$binaryPath + ); + } + } else { + if (! is_executable($binaryPath)) { + throw new \InvalidArgumentException( + 'Binary is not executable: '.$binaryPath + ); + } - // Check file permissions (should not be world-writable) - $perms = fileperms($binaryPath); - if ($perms & 0002) { - throw new \InvalidArgumentException( - 'Binary is world-writable, which is a security risk: '.$binaryPath - ); + // Check file permissions on Unix (should not be world-writable) + $perms = fileperms($binaryPath); + if ($perms & 0002) { + throw new \InvalidArgumentException( + 'Binary is world-writable, which is a security risk: '.$binaryPath + ); + } } - - // Optionally check file ownership (uncomment if needed) - // $owner = fileowner($binaryPath); - // if ($owner !== getmyuid()) { - // throw new \InvalidArgumentException( - // 'Binary is not owned by current user: ' . $binaryPath - // ); - // } } /** diff --git a/app/Services/VoiceNarrationService.php b/app/Services/VoiceNarrationService.php deleted file mode 100644 index 1812a33..0000000 --- a/app/Services/VoiceNarrationService.php +++ /dev/null @@ -1,160 +0,0 @@ -resolveNarrator($config->voice); - $speech = $narrator->generate($content, $config); - $this->speak($speech, $config); - } catch (\RuntimeException $e) { - // Fallback to simple text output if narrator system fails - $this->handleNarratorFailure($content, $e); - } - } - - private function handleNarratorFailure(NarrationContent $content, \RuntimeException $e): void - { - // Log the error for debugging - if (function_exists('logger')) { - logger()->warning("Voice narrator failed: {$e->getMessage()}"); - } - - // Fallback to basic text summary - $fallbackText = $this->generateFallbackText($content); - $this->fallbackToText($fallbackText); - } - - private function generateFallbackText(NarrationContent $content): string - { - $parts = []; - - $parts[] = "Title: {$content->title}"; - $parts[] = "Author: {$content->author}"; - $parts[] = "Status: {$content->state}"; - - if ($content->description) { - $parts[] = 'Description: '.substr($content->description, 0, 100).'...'; - } - - return implode('. ', $parts).'.'; - } - - private function resolveNarrator(VoiceStyle $voice): VoiceNarratorInterface - { - // Try requested voice style first - $narrator = $this->narrators->get($voice->value); - - if ($narrator) { - return $narrator; - } - - // Fallback to default narrator - $defaultNarrator = $this->narrators->get('default'); - - if ($defaultNarrator) { - return $defaultNarrator; - } - - // If no narrators available, throw descriptive error - throw new \RuntimeException( - "No narrator available for style '{$voice->value}' and no default narrator configured. ". - 'Available narrators: '.implode(', ', $this->narrators->keys()->toArray()) - ); - } - - public function speak(string $speech, SpeechConfiguration $config): void - { - $platform = $this->detectPlatform(); - - match ($platform) { - 'darwin' => $this->speakMacOS($speech, $config), - 'windows' => $this->speakWindows($speech, $config), - 'linux' => $this->speakLinux($speech, $config), - default => $this->fallbackToText($speech), - }; - } - - private function detectPlatform(): string - { - return strtolower(PHP_OS_FAMILY); - } - - private function speakMacOS(string $speech, SpeechConfiguration $config): void - { - $rate = match ($config->speed) { - SpeechSpeed::Slow => 100, - SpeechSpeed::Fast => 200, - default => 140, - }; - - $command = sprintf('say -r %d %s', $rate, escapeshellarg($speech)); - shell_exec($command); - } - - private function speakWindows(string $speech, SpeechConfiguration $config): void - { - // Windows trolling included 😈 - sleep(1); - - $trolledSpeech = 'Why are you using Windows for development? '. - 'Get a real operating system first. '. - "Anyway, here's your briefing from the inferior platform: ".$speech; - - $rate = match ($config->speed) { - SpeechSpeed::Slow => -4, - SpeechSpeed::Fast => 0, - default => -2, - }; - - $psCommand = sprintf( - 'Add-Type -AssemblyName System.Speech; '. - '$speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; '. - '$speak.Rate = %d; '. - '$speak.Speak(%s); '. - '$speak.Dispose()', - $rate, - escapeshellarg($trolledSpeech) - ); - - shell_exec("powershell -Command \"{$psCommand}\""); - } - - private function speakLinux(string $speech, SpeechConfiguration $config): void - { - if (shell_exec('which espeak 2>/dev/null')) { - $speedFlag = match ($config->speed) { - SpeechSpeed::Slow => '-s 120', - SpeechSpeed::Fast => '-s 200', - default => '-s 160', - }; - shell_exec("espeak {$speedFlag} ".escapeshellarg($speech)); - } else { - $this->fallbackToText($speech); - } - } - - private function fallbackToText(string $speech): void - { - echo "\nšŸ“ Text version:\n"; - echo "═══════════════\n"; - echo $speech."\n"; - echo "═══════════════\n"; - } -} diff --git a/app/ValueObjects/CodeRabbitAnalysis.php b/app/ValueObjects/CodeRabbitAnalysis.php deleted file mode 100644 index 0185128..0000000 --- a/app/ValueObjects/CodeRabbitAnalysis.php +++ /dev/null @@ -1,82 +0,0 @@ -totalComments === 0) { - return 'CodeRabbit found no issues with this PR. Clean code, ready to ship!'; - } - - $summary = $this->aiSummary['executive_summary'] ?? 'CodeRabbit provided feedback on this PR.'; - - $priorityBreakdown = $this->getPriorityBreakdown(); - $topFiles = $this->getTopFilesWithIssues(); - $keyThemes = $this->aiSummary['key_themes'] ?? []; - - $narration = "{$summary} "; - $narration .= "Total feedback: {$this->totalComments} comments. "; - $narration .= $priorityBreakdown.' '; - - if (! empty($topFiles)) { - $narration .= 'Main files needing attention: '.implode(', ', $topFiles).'. '; - } - - if (! empty($keyThemes)) { - $narration .= 'Key themes: '.implode(', ', array_slice($keyThemes, 0, 3)).'. '; - } - - $actionPriorities = $this->aiSummary['action_priorities'] ?? []; - if (! empty($actionPriorities)) { - $narration .= 'Top priority: '.$actionPriorities[0].'.'; - } - - return $narration; - } - - private function getPriorityBreakdown(): string - { - $priorities = $this->rawComments->groupBy('priority')->map->count(); - - $parts = []; - if ($priorities->get('high', 0) > 0) { - $parts[] = "{$priorities['high']} high priority"; - } - if ($priorities->get('medium', 0) > 0) { - $parts[] = "{$priorities['medium']} medium priority"; - } - if ($priorities->get('low', 0) > 0) { - $parts[] = "{$priorities['low']} low priority"; - } - - return empty($parts) ? '' : 'Breakdown: '.implode(', ', $parts).'.'; - } - - private function getTopFilesWithIssues(int $limit = 3): array - { - return array_keys( - array_slice( - array_filter($this->commentsByFile, fn ($file) => $file !== null), - 0, - $limit, - true - ) - ); - } -} diff --git a/app/ValueObjects/NarrationContent.php b/app/ValueObjects/NarrationContent.php deleted file mode 100644 index 8c03529..0000000 --- a/app/ValueObjects/NarrationContent.php +++ /dev/null @@ -1,148 +0,0 @@ - collect($issue['labels'] ?? [])->pluck('name')->toArray(), - 'assignees' => collect($issue['assignees'] ?? [])->pluck('login')->toArray(), - 'created_at' => $issue['created_at'] ?? null, - 'updated_at' => $issue['updated_at'] ?? null, - ], - comments: $comments, - ); - } - - public static function fromPullRequest(array $pr, ?Collection $comments = null, ?Collection $reviews = null): self - { - return new self( - type: 'pull_request', - number: $pr['number'], - title: $pr['title'], - description: self::sanitizeText($pr['body'] ?? ''), - state: $pr['state'], - author: $pr['user']['login'] ?? 'Unknown', - metadata: [ - 'draft' => $pr['draft'] ?? false, - 'mergeable' => $pr['mergeable'] ?? null, - 'additions' => $pr['additions'] ?? 0, - 'deletions' => $pr['deletions'] ?? 0, - 'changed_files' => $pr['changed_files'] ?? 0, - 'commits' => $pr['commits'] ?? 0, - 'base_branch' => $pr['base']['ref'] ?? 'main', - 'head_branch' => $pr['head']['ref'] ?? 'feature', - 'created_at' => $pr['created_at'] ?? null, - 'updated_at' => $pr['updated_at'] ?? null, - ], - comments: $comments, - reviews: $reviews, - ); - } - - private static function sanitizeText(string $text): string - { - // Remove markdown formatting - $text = strip_tags($text); - - // Remove excessive whitespace and line breaks - $text = preg_replace('/\s+/', ' ', $text); - - // Truncate for speech - if (strlen($text) > 300) { - $text = substr($text, 0, 300).'... and more details'; - } - - return trim($text); - } - - public function getStatsSummary(): string - { - if ($this->type === 'pull_request') { - $additions = $this->metadata['additions'] ?? 0; - $deletions = $this->metadata['deletions'] ?? 0; - $files = $this->metadata['changed_files'] ?? 0; - $commits = $this->metadata['commits'] ?? 0; - - return "Statistics: {$additions} additions, {$deletions} deletions, {$files} files changed, {$commits} commits"; - } - - return ''; - } - - public function getCommentsSummary(): string - { - if (! $this->comments || $this->comments->isEmpty()) { - return 'No comments yet.'; - } - - $total = $this->comments->count(); - $recentComments = $this->comments->sortByDesc('created_at')->take(3); - - $summary = "There are {$total} comments. "; - - if ($total <= 3) { - $summary .= 'Recent discussion includes: '; - $summary .= $recentComments->map(function ($comment) { - $author = $comment['user']['login'] ?? 'Someone'; - $snippet = substr(strip_tags($comment['body'] ?? ''), 0, 50); - - return "{$author} said: {$snippet}"; - })->join('. '); - } else { - $summary .= 'Most recent comments from: '; - $summary .= $recentComments->pluck('user.login')->unique()->join(', '); - } - - return $summary; - } - - public function getReviewsSummary(): string - { - if (! $this->reviews || $this->reviews->isEmpty()) { - return 'No reviews yet.'; - } - - $approved = $this->reviews->where('state', 'APPROVED')->count(); - $requestedChanges = $this->reviews->where('state', 'CHANGES_REQUESTED')->count(); - $commented = $this->reviews->where('state', 'COMMENTED')->count(); - - $parts = []; - if ($approved > 0) { - $parts[] = "{$approved} approvals"; - } - if ($requestedChanges > 0) { - $parts[] = "{$requestedChanges} change requests"; - } - if ($commented > 0) { - $parts[] = "{$commented} review comments"; - } - - return 'Review status: '.implode(', ', $parts); - } -} diff --git a/app/ValueObjects/SpeechConfiguration.php b/app/ValueObjects/SpeechConfiguration.php deleted file mode 100644 index 95acdd2..0000000 --- a/app/ValueObjects/SpeechConfiguration.php +++ /dev/null @@ -1,35 +0,0 @@ -claudePrompt); - } -} diff --git a/composer.json b/composer.json index b0ee889..2472392 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,10 @@ "chillerlan/php-qrcode": "^5.0", "guzzlehttp/guzzle": "^7.8", "illuminate/database": "^11.45", + "illuminate/log": "^11.0", "jordanpartridge/github-client": "^2.9", "laravel-zero/framework": "^11.36.1", + "symfony/filesystem": "^7.4", "symfony/process": "^6.0|^7.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 67a8eb9..951a94f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8418156dbdb1d976725a332a99924559", + "content-hash": "7a9228796df5de701adfd673425ef162", "packages": [ { "name": "brick/math", @@ -355,33 +355,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -426,7 +425,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -442,7 +441,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "dragonmantank/cron-expression", @@ -1147,7 +1146,7 @@ }, { "name": "illuminate/collections", - "version": "v11.45.1", + "version": "v11.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", @@ -1203,7 +1202,7 @@ }, { "name": "illuminate/conditionable", - "version": "v11.45.1", + "version": "v11.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1414,16 +1413,16 @@ }, { "name": "illuminate/contracts", - "version": "v11.45.1", + "version": "v11.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "4b2a67d1663f50085bc91e6371492697a5d2d4e8" + "reference": "4787042340aae19a7ea0fa82f4073c4826204a48" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/4b2a67d1663f50085bc91e6371492697a5d2d4e8", - "reference": "4b2a67d1663f50085bc91e6371492697a5d2d4e8", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/4787042340aae19a7ea0fa82f4073c4826204a48", + "reference": "4787042340aae19a7ea0fa82f4073c4826204a48", "shasum": "" }, "require": { @@ -1458,7 +1457,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-24T11:54:20+00:00" + "time": "2025-11-27T16:16:07+00:00" }, { "name": "illuminate/database", @@ -1651,9 +1650,62 @@ }, "time": "2025-03-24T11:54:20+00:00" }, + { + "name": "illuminate/log", + "version": "v11.47.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/log.git", + "reference": "73bcd8423739c751442b7e618153c73f78e844cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/log/zipball/73bcd8423739c751442b7e618153c73f78e844cd", + "reference": "73bcd8423739c751442b7e618153c73f78e844cd", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0", + "illuminate/support": "^11.0", + "monolog/monolog": "^3.0", + "php": "^8.2", + "psr/log": "^1.0|^2.0|^3.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Log\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Log package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, { "name": "illuminate/macroable", - "version": "v11.45.1", + "version": "v11.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -1798,16 +1850,16 @@ }, { "name": "illuminate/support", - "version": "v11.45.1", + "version": "v11.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "9732f41d7a9836a2c466ab06460efc732aeb417a" + "reference": "20fbd9f9f502a55de0cbba3f3f81444b7c44af4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/9732f41d7a9836a2c466ab06460efc732aeb417a", - "reference": "9732f41d7a9836a2c466ab06460efc732aeb417a", + "url": "https://api.github.com/repos/illuminate/support/zipball/20fbd9f9f502a55de0cbba3f3f81444b7c44af4b", + "reference": "20fbd9f9f502a55de0cbba3f3f81444b7c44af4b", "shasum": "" }, "require": { @@ -1871,7 +1923,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-11T20:47:08+00:00" + "time": "2025-11-27T16:16:32+00:00" }, { "name": "illuminate/testing", @@ -2676,18 +2728,121 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, { "name": "nesbot/carbon", - "version": "3.10.1", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", "shasum": "" }, "require": { @@ -2695,9 +2850,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2705,13 +2860,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.75.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpunit/phpunit": "^10.5.46", - "squizlabs/php_codesniffer": "^3.13.0" + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -2779,7 +2934,7 @@ "type": "tidelift" } ], - "time": "2025-06-21T15:19:35+00:00" + "time": "2025-12-02T21:04:28+00:00" }, { "name": "nunomaduro/collision", @@ -3990,16 +4145,16 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -4044,7 +4199,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -4055,12 +4210,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/console", @@ -4456,6 +4615,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, { "name": "symfony/finder", "version": "v7.3.0", @@ -4522,7 +4751,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -4581,7 +4810,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -4592,6 +4821,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -4760,7 +4993,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -4821,7 +5054,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -4832,6 +5065,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -4841,7 +5078,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -4901,7 +5138,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -4912,6 +5149,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -4921,16 +5162,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -4977,7 +5218,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -4988,12 +5229,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { "name": "symfony/process", @@ -5058,16 +5303,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -5121,7 +5366,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -5132,12 +5377,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", @@ -5228,23 +5477,23 @@ }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", @@ -5263,17 +5512,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5304,7 +5553,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.4.0" }, "funding": [ { @@ -5315,25 +5564,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -5382,7 +5635,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -5393,12 +5646,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/var-dumper", diff --git a/tests/Feature/ComponentSecurityTest.php b/tests/Feature/ComponentSecurityTest.php index dc9dd6f..aee30c8 100644 --- a/tests/Feature/ComponentSecurityTest.php +++ b/tests/Feature/ComponentSecurityTest.php @@ -74,14 +74,19 @@ public function it_prevents_path_traversal_in_component_paths() // This should be caught by the security validator $components = $discovery->discover(); + // Get home directory (cross-platform) + $home = getenv('HOME') ?: getenv('USERPROFILE') ?: sys_get_temp_dir(); + // Verify no components from outside allowed paths foreach ($components as $component) { - $path = $component['path']; + $path = str_replace('\\', '/', $component['path']); + $basePath = str_replace('\\', '/', base_path('components/')); + $homePath = str_replace('\\', '/', $home.'/.conduit/components/'); // Should be within allowed directories $this->assertTrue( - str_starts_with($path, base_path('components/')) || - str_starts_with($path, $_SERVER['HOME'].'/.conduit/components/'), + str_starts_with($path, $basePath) || + str_starts_with($path, $homePath), "Component path should be within allowed directories: $path" ); } @@ -90,6 +95,11 @@ public function it_prevents_path_traversal_in_component_paths() /** @test */ public function it_sanitizes_user_arguments_before_delegation() { + // Skip on Windows - bash scripts don't work the same way + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Test requires bash, skipped on Windows'); + } + // Create a test component directory $testDir = base_path('components/core/test'); $testBinary = $testDir.'/test'; @@ -136,6 +146,11 @@ public function it_sanitizes_user_arguments_before_delegation() /** @test */ public function it_validates_binary_permissions_before_execution() { + // Skip on Windows - chmod doesn't work the same way + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Unix permissions test, skipped on Windows'); + } + $validator = app(ComponentSecurityValidator::class); // Create test directory @@ -164,6 +179,11 @@ public function it_validates_binary_permissions_before_execution() /** @test */ public function it_handles_malformed_component_commands_safely() { + // Skip on Windows - bash scripts don't work the same way + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Test requires bash, skipped on Windows'); + } + $discovery = app(StandaloneComponentDiscovery::class); // Create a test component with malicious command names in config diff --git a/tests/Unit/Enums/SpeechSpeedTest.php b/tests/Unit/Enums/SpeechSpeedTest.php deleted file mode 100644 index e8ed2b1..0000000 --- a/tests/Unit/Enums/SpeechSpeedTest.php +++ /dev/null @@ -1,21 +0,0 @@ -value)->toBe('slow'); - expect(SpeechSpeed::Normal->value)->toBe('normal'); - expect(SpeechSpeed::Fast->value)->toBe('fast'); - expect(SpeechSpeed::Blazing->value)->toBe('blazing'); -}); - -it('can list all cases', function () { - $cases = SpeechSpeed::cases(); - expect($cases)->toHaveCount(4); - expect(array_map(fn ($case) => $case->value, $cases))->toBe([ - 'slow', - 'normal', - 'fast', - 'blazing', - ]); -}); diff --git a/tests/Unit/Enums/VoiceStyleTest.php b/tests/Unit/Enums/VoiceStyleTest.php deleted file mode 100644 index eb14324..0000000 --- a/tests/Unit/Enums/VoiceStyleTest.php +++ /dev/null @@ -1,21 +0,0 @@ -value)->toBe('default'); - expect(VoiceStyle::Dramatic->value)->toBe('dramatic'); - expect(VoiceStyle::Sarcastic->value)->toBe('sarcastic'); - expect(VoiceStyle::Coach->value)->toBe('coach'); - expect(VoiceStyle::Robot->value)->toBe('robot'); - expect(VoiceStyle::Reviewer->value)->toBe('reviewer'); - expect(VoiceStyle::Executive->value)->toBe('executive'); - expect(VoiceStyle::Zen->value)->toBe('zen'); - expect(VoiceStyle::Pirate->value)->toBe('pirate'); - expect(VoiceStyle::Documentary->value)->toBe('documentary'); -}); - -it('can list all cases', function () { - $cases = VoiceStyle::cases(); - expect($cases)->toHaveCount(10); -}); diff --git a/tests/Unit/Services/Security/ComponentSecurityValidatorTest.php b/tests/Unit/Services/Security/ComponentSecurityValidatorTest.php index 6a9a3c7..d5a6e07 100644 --- a/tests/Unit/Services/Security/ComponentSecurityValidatorTest.php +++ b/tests/Unit/Services/Security/ComponentSecurityValidatorTest.php @@ -134,7 +134,10 @@ public function it_validates_component_paths_within_allowed_directories() $validPath = $basePath.'/github'; $result = $this->validator->validateComponentPath($validPath); - $this->assertStringStartsWith($basePath, $result); + // Normalize both paths to forward slashes for comparison (cross-platform) + $normalizedResult = str_replace('\\', '/', $result); + $normalizedBase = str_replace('\\', '/', $basePath); + $this->assertStringStartsWith($normalizedBase, $normalizedResult); } /** @test */ @@ -171,9 +174,9 @@ public function it_detects_path_traversal_attempts() /** @test */ public function it_builds_safe_command_arrays() { - // Create a test directory structure - $testComponentDir = base_path('components/core/test-component'); - $testBinary = $testComponentDir.'/test-component'; + // Create a test directory structure with unique name + $testComponentDir = base_path('components/core/test-cmd-arrays'); + $testBinary = $testComponentDir.'/test-cmd-arrays'; // Add test path to allowed paths $this->validator->addAllowedPath($testComponentDir); @@ -183,7 +186,9 @@ public function it_builds_safe_command_arrays() mkdir($testComponentDir, 0755, true); } touch($testBinary); - chmod($testBinary, 0755); + if (PHP_OS_FAMILY !== 'Windows') { + chmod($testBinary, 0755); + } $result = $this->validator->buildSafeCommand( $testBinary, @@ -193,17 +198,20 @@ public function it_builds_safe_command_arrays() ); // Check the command array is properly sanitized - $this->assertEquals($testBinary, $result[0]); + // Normalize path separators for cross-platform comparison + $this->assertEquals(str_replace('\\', '/', $testBinary), str_replace('\\', '/', $result[0])); $this->assertEquals('delegated', $result[1]); $this->assertEquals('test:command', $result[2]); - $this->assertEquals("'arg1'", $result[3]); - $this->assertEquals("'arg with space'", $result[4]); - $this->assertEquals("'arg;with;semicolon'", $result[5]); + // escapeshellarg() uses double quotes on Windows + $quote = PHP_OS_FAMILY === 'Windows' ? '"' : "'"; + $this->assertEquals("{$quote}arg1{$quote}", $result[3]); + $this->assertEquals("{$quote}arg with space{$quote}", $result[4]); + $this->assertEquals("{$quote}arg;with;semicolon{$quote}", $result[5]); $this->assertEquals('--option1', $result[6]); - $this->assertEquals("'value1'", $result[7]); + $this->assertEquals("{$quote}value1{$quote}", $result[7]); $this->assertEquals('--flag', $result[8]); $this->assertEquals('--dangerous', $result[9]); - $this->assertEquals("'val;ue'", $result[10]); + $this->assertEquals("{$quote}val;ue{$quote}", $result[10]); // Clean up unlink($testBinary); @@ -213,8 +221,8 @@ public function it_builds_safe_command_arrays() /** @test */ public function it_validates_binary_integrity() { - $testComponentDir = base_path('components/core/test-component'); - $testBinary = $testComponentDir.'/test-component'; + $testComponentDir = base_path('components/core/test-binary-integrity'); + $testBinary = $testComponentDir.'/test-binary-integrity'; // Add test path to allowed paths $this->validator->addAllowedPath($testComponentDir); @@ -224,33 +232,41 @@ public function it_validates_binary_integrity() } // Test non-existent binary - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Binary does not exist'); - $this->validator->validateBinaryIntegrity($testBinary); - - // Create binary - touch($testBinary); - - // Test non-executable binary - chmod($testBinary, 0644); try { $this->validator->validateBinaryIntegrity($testBinary); - $this->fail('Should have thrown exception for non-executable binary'); + $this->fail('Should have thrown exception for non-existent binary'); } catch (\InvalidArgumentException $e) { - $this->assertStringContainsString('not executable', $e->getMessage()); + $this->assertStringContainsString('does not exist', $e->getMessage()); } - // Test world-writable binary - chmod($testBinary, 0777); - try { - $this->validator->validateBinaryIntegrity($testBinary); - $this->fail('Should have thrown exception for world-writable binary'); - } catch (\InvalidArgumentException $e) { - $this->assertStringContainsString('world-writable', $e->getMessage()); + // Create binary + touch($testBinary); + + // Skip Unix-specific permission tests on Windows (chmod doesn't work the same) + if (PHP_OS_FAMILY !== 'Windows') { + // Test non-executable binary + chmod($testBinary, 0644); + try { + $this->validator->validateBinaryIntegrity($testBinary); + $this->fail('Should have thrown exception for non-executable binary'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('not executable', $e->getMessage()); + } + + // Test world-writable binary + chmod($testBinary, 0777); + try { + $this->validator->validateBinaryIntegrity($testBinary); + $this->fail('Should have thrown exception for world-writable binary'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('world-writable', $e->getMessage()); + } + + // Test valid binary + chmod($testBinary, 0755); } - // Test valid binary - chmod($testBinary, 0755); + // On all platforms, a readable file should pass (Windows uses different permission model) $this->validator->validateBinaryIntegrity($testBinary); // Should not throw // Clean up diff --git a/tests/Unit/ValueObjects/SpeechConfigurationTest.php b/tests/Unit/ValueObjects/SpeechConfigurationTest.php deleted file mode 100644 index d41540f..0000000 --- a/tests/Unit/ValueObjects/SpeechConfigurationTest.php +++ /dev/null @@ -1,39 +0,0 @@ - 'dramatic', - 'speed' => 'fast', - 'include-stats' => true, - 'include-comments' => true, - 'claude' => 'custom prompt', - ]); - - expect($config->voice)->toBe(VoiceStyle::Dramatic); - expect($config->speed)->toBe(SpeechSpeed::Fast); - expect($config->includeStats)->toBeTrue(); - expect($config->includeComments)->toBeTrue(); - expect($config->claudePrompt)->toBe('custom prompt'); -}); - -it('uses default values when options are empty', function () { - $config = SpeechConfiguration::fromOptions([]); - - expect($config->voice)->toBe(VoiceStyle::Default); - expect($config->speed)->toBe(SpeechSpeed::Normal); - expect($config->includeStats)->toBeFalse(); - expect($config->includeComments)->toBeFalse(); - expect($config->claudePrompt)->toBeNull(); -}); - -it('identifies claude powered based on prompt presence', function () { - $withClaude = SpeechConfiguration::fromOptions(['claude' => 'some prompt']); - expect($withClaude->isClaudePowered())->toBeTrue(); - - $withoutClaude = SpeechConfiguration::fromOptions([]); - expect($withoutClaude->isClaudePowered())->toBeFalse(); -});