Fix/2.7.2 beta12 (#424)
* feat(mcp): add per-project MCP server configuration - Add mcpServers config to ProjectEnvConfig type for per-project overrides - Update env-handlers to read/write MCP config from .auto-claude/.env - Update backend get_required_mcp_servers() to respect project config - Refactor AgentTools.tsx to show project-specific MCP toggles - Move MCP Overview to Project section in sidebar navigation - Add i18n translations for MCP server names and descriptions - Update tests for new mcp_config parameter behavior Users can now enable/disable Context7, Linear, Electron, and Puppeteer MCP servers on a per-project basis. Settings are stored in each project's .auto-claude/.env file and respected by the backend when starting agents. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): send final plan state before unwatching on task exit The file watcher was being stopped before the final plan state could be sent to the renderer. This caused tasks to show stale data (0/0 subtasks) in the UI when they had actually completed successfully with subtasks. Now the final plan is sent to the renderer before unwatching, ensuring the UI receives the correct subtask count and completion status. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci(beta-release): add Flatpak packaging support for Linux builds The Linux build was failing with "spawn flatpak ENOENT" because the beta-release workflow was missing the Flatpak setup step that was added to the main release workflow in #404. Adds: - Setup Flatpak step with flatpak-builder and required runtimes - .flatpak to artifact uploads and validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ui): make kanban columns responsive to available width Fixed-width columns wasted horizontal space on wider displays and unnecessarily truncated task titles. Columns now grow with flex-1 while respecting min/max bounds (288-480px for tasks, 320-512px for roadmap). Badge area also expanded slightly (160→180px) to accommodate wider cards. * feat(settings): add user-configurable utility agent settings Make merge_resolver and commit_message agents configurable via the new "Utility" feature setting in Agent Settings. Previously these were hardcoded to Haiku with low thinking, but now users can select their preferred model and thinking level. Changes: - Add utility feature key to FeatureModelConfig/FeatureThinkingConfig - Update AgentTools.tsx to use feature settings instead of fixed - Pass UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET env vars to backend - Backend merge_resolver and commit_message read from env vars - Add i18n translations for utility settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove unused agent-tools entry from sidebar navigation This commit cleans up the Sidebar component by removing the 'agent-tools' entry from the tools navigation items, streamlining the user interface. The 'worktrees' entry remains intact, ensuring continued access to relevant features. * fix(robustness): address PR review findings for error handling and validation Fix 9 issues identified in PR #424 review: Medium issues: - Add try/except for int() conversion of UTILITY_THINKING_BUDGET env var - Pass '0' when thinking level is 'none' to properly disable extended thinking - Add error handling (|| exit 1) for Flatpak install commands in CI Low issues: - Log exceptions in load_project_mcp_config instead of silent pass - Handle None input in _map_mcp_server_name to prevent AttributeError - Cast mcp_config values to string before split() to handle non-string values - Filter effectiveMcps by project-level MCP states in AgentTools - Log JSON parse errors in getUtilitySettings for easier debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(ci): remove CLA workflow (using hosted CLA Assistant) The CLA workflow was accidentally re-added by PR #254. We use the hosted CLA Assistant service (cla-assistant.io) which handles CLA signing via GitHub webhooks, so this workflow file is redundant and causes failing checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): correct graphiti-memory server name in MCP filter The switch case incorrectly used 'graphiti' instead of 'graphiti-memory' which is the actual server ID used throughout the codebase. This caused the filter to not properly check the graphiti MCP server enabled state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(utility): correctly disable extended thinking when set to "none" When utility thinking level is set to "none", the frontend was sending '0' to the backend, which parsed it as integer 0. The SDK expects max_thinking_tokens=None to disable extended thinking, not 0. Frontend now sends empty string for disabled thinking, and backend interprets empty string as None instead of falling back to 1024. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix issue with github PR checking bot detection * feat(ui): add Claude Code CLI detection and one-click installation Adds comprehensive Claude Code CLI integration to the frontend: - New onboarding step to check if Claude Code is installed - Persistent status badge in sidebar showing version status - Version checking against npm registry with 24h cache - One-click install/update using user's preferred terminal - Cross-platform support (macOS, Windows, Linux) with 20+ terminals - Warning dialog before updates to prevent data loss from killed sessions - Automatic detection of running Claude processes with graceful termination - Added ~/.local/bin to macOS PATH search for Claude CLI detection - Full i18n support (English and French translations) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ideation): close panel on dismiss and scope events by project Two bugs fixed based on user feedback: 1. Dismiss idea panel not closing: The detail panel now calls onClose() after dismissing, so it closes automatically instead of staying open with hidden action buttons. 2. Idea regeneration affecting all projects: Added currentProjectId tracking to the ideation store. All IPC listeners now filter events by projectId, preventing cross-project state contamination when multiple projects are open. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(github): add parallel orchestrator for PR reviews Implement AI-orchestrated parallel review system using Claude Agent SDK subagents for both initial and follow-up PR reviews. Initial review uses 5 specialist agents: - security-reviewer: OWASP Top 10, injection, auth issues - quality-reviewer: complexity, duplication, error handling - logic-reviewer: algorithm correctness, edge cases, race conditions - codebase-fit-reviewer: naming conventions, pattern adherence - ai-triage-reviewer: validate CodeRabbit, Cursor, Gemini comments Follow-up review uses 3 specialist agents: - resolution-verifier: AI-powered verification of previous findings - new-code-reviewer: security/logic/quality checks on new code - comment-analyzer: triage contributor and AI bot feedback Key features: - AI decides which agents to invoke (not programmatic rules) - User-configurable models via frontend settings (no hardcoding) - SDK handles parallel execution automatically - Cross-validation boosts confidence when agents agree Also fixes bot_detection.py import error for relative imports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(core): add agents parameter to create_client for SDK subagents The create_client function was missing support for the `agents` parameter needed by the parallel orchestrator reviewers to define SDK subagents. This enables the parallel PR review system to define specialist agents (resolution-verifier, new-code-reviewer, comment-analyzer) that the SDK can execute in parallel. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(security): add usedforsecurity=False to MD5 hash for finding IDs The MD5 hash is used for generating unique finding IDs (non-security purpose), so Bandit B324 warning is addressed by marking it explicitly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(github): add PR review logs feature and parallel orchestrator improvements - Add PR logs feature to view AI review thinking/tool usage during analysis - Create PRLogCollector class to capture and structure subprocess output - Add PRLogs component with collapsible phases (context, analysis, synthesis) - Improve parallel orchestrator and followup reviewer with better agent coordination - Add bot detection improvements and fix benefit-of-doubt logic - Add comprehensive tests for PR review, bot detection, and E2E flows - Add IPC channel and browser mock for PR logs retrieval - Add i18n translations for review logs UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): show PR review logs during AI analysis in progress - Add logs section that appears when review is in progress, not just after completion - Add periodic log refresh (2s interval) while review is streaming - Add isStreaming prop to PRLogs component for live indicator - Show "Live" badge on logs header during active review - Show streaming status on active phases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): show actual AI response content in PR review logs - Update Python orchestrators to print AI response text preview (up to 500 chars) - Filter out unhelpful debug messages like "Message #15: AssistantMessage" - Add ParallelOrchestrator to log source patterns and color mapping - Move Followup to analysis phase (not context) for better categorization The logs now show actual AI thinking and responses instead of just message types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): capture synthesis phase logs and mark all phases complete - Add parsing for [PR Review Engine], [PR #XXX] progress, and Summary lines - Add PR progress message pattern matching for [PR #XXX] [YY%] format - Map PR Review Engine, Summary, and Progress sources to synthesis phase - Update finalize() to mark pending phases as completed when review succeeds - Add source colors for PR Review Engine (indigo) and Summary (emerald) The synthesis phase now properly shows logs and marks as Complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ui): merge tools section into project and add dynamic nav filtering Moves GitHub Issues, GitHub PRs, GitLab Issues, and GitLab MRs tabs from the separate TOOLS section into the PROJECT section. Navigation items are now dynamically filtered based on project settings - GitHub tabs only show when GitHub is enabled, and GitLab tabs only show when GitLab is enabled. This reduces visual clutter by hiding integrations that aren't configured while consolidating all project-related navigation into a single section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(pr-review): implement strict quality gates severity system Redesign PR review severity labels and verdict logic based on research: - CRITICAL → "Blocker" (blocks merge) - HIGH → "Required" (blocks merge) - MEDIUM → "Recommended" (blocks merge - AI fixes quickly) - LOW → "Suggestion" (optional) Key changes: - Medium severity findings now result in NEEDS_REVISION verdict - Only LOW severity allows MERGE_WITH_CHANGES - Updated all 5 verdict logic files for consistency - Updated AI prompts with strict quality gates guidance - Updated en/fr i18n labels with action-oriented terminology Rationale: AI can fix code issues quickly, so be aggressive about code quality. 95% of fixes are done by AI anyway. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(mcp): add health checks and bearer token auth for custom MCP servers Users adding HTTP MCP servers had no way to know if the server was healthy, needed authentication, or was unreachable. This adds: - Health status indicators (healthy/needs auth/unhealthy/checking) - Quick connectivity check on component mount - Manual "Test" button for full MCP protocol test - Simple "Authentication Token" field that creates Bearer header - URL pattern detection with helpful hints for known providers (GitHub, Google, Anthropic, OpenAI) with links to create tokens - Collapsible "Advanced Headers" section for custom headers - i18n translations for all new fields (EN/FR) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(gitlab): add glab CLI detection and one-click install When users try to use OAuth for GitLab authentication, the app now checks if glab CLI is installed first. If not installed, it shows an inline warning card with a one-click install button that opens the user's preferred terminal with the appropriate install command (brew for macOS, winget for Windows, snap/brew for Linux). This prevents the silent failure that occurred when glab was missing, where the OAuth flow would fail with ENOENT and leave users with a perpetual loading spinner. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): pass USE_CLAUDE_MD env var to subprocess The PR review subprocess wasn't receiving the project's useClaudeMd setting, causing it to always show "CLAUDE.md: disabled by project settings" even when enabled in the UI. Changes: - Added optional `env` parameter to SubprocessOptions interface - Updated runPythonSubprocess to merge custom env vars with filtered env - PR handlers now pass USE_CLAUDE_MD based on project.settings.useClaudeMd 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): improve agent invocation and findings logging The logs previously showed "Agents invoked: []" even when agents were running because the streaming detection wasn't reliable. Also, the findings summary was hidden. Changes: - Extract agents from structured output (reliable source) instead of streaming detection which was returning empty - Log each specialist agent with [Agent:name] label when complete - Add detailed findings summary showing severity, title, file:line - Both parallel orchestrator and followup reviewer updated Example new log output: [ParallelOrchestrator] Specialist agents invoked: security-reviewer, logic-reviewer [Agent:security-reviewer] Analysis complete [Agent:logic-reviewer] Analysis complete [ParallelOrchestrator] Findings summary: [LOW] 1. Suggestion title (file.ts:42) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(pr-review): enhance quality gates and logging for PR assessments Updated the PR review process to enforce stricter quality gates for severity levels, ensuring that HIGH and MEDIUM issues block merges. Adjusted the verdict criteria for clarity and consistency across the system. Enhanced logging for PR reviews to provide real-time updates and improved user feedback. Changes: - Revised verdict criteria to reflect strict quality gates - Updated logging to capture all findings, including LOW severity suggestions - Improved real-time log streaming during PR reviews This ensures a more robust and user-friendly review process, emphasizing the importance of addressing all findings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * feat(pr-review): add real-time log streaming and specialist agent tracking - Add incremental log saving in PRLogCollector (every 3 entries) for real-time streaming - Add phase transition tracking to properly mark phases as complete - Add parsing for specialist agent logs ([Agent:xxx] format) - Add color-coded badges for specialist agents in frontend logs UI - Track subagent invocations via ToolUseBlock/ToolResultBlock in message content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): improve verdict display, follow-up timing, and findings UX Three fixes for PR review: 1. Verdict message now includes LOW findings count (e.g., "1 required, 0 recommended, 4 suggestions") 2. "Ready for Follow-up" only shows when commits happen AFTER findings are posted, not during/before the review 3. Posted findings are hidden from selection UI - shows "All findings posted to GitHub" instead of confusing "0/5 selected" Also removes legacy orchestrator_reviewer.py (dead code superseded by parallel orchestrator) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(review): address PR #424 code review feedback Fixes issues flagged by CodeRabbit, Cursor bot, and GitHub security: - Fix nullish coalescing for 'none' thinking budget (worktree-handlers.ts) - Add set -e to Flatpak CI setup for consistent error handling - Add explicit UTF-8 encoding to file open in client.py - Add type="button" to 10 buttons to prevent form submissions - Remove unused isLoading and getServerStatus variables - Improve French translations with proper definite articles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): ensure synthesis tab shows completed status after review When a follow-up review completed, the Synthesis tab would continue showing "Running" instead of "Complete". This was due to a race condition where the log polling stopped immediately when isReviewing became false, before fetching the final log state with completed phase statuses. Added a final log refresh when the review completes by tracking the previous isReviewing state and fetching logs one more time when the state transitions from true to false. * fix(security): address code review security and quality issues Fixes security vulnerabilities and code quality issues from Auto Claude review: - Fix command injection: use execFileSync with args array instead of template string interpolation for git commands (worktree-handlers.ts) - Fix JSON injection: add schema validation for CUSTOM_MCP_SERVERS to reject malicious configurations (client.py) - Fix code smell: use proper destructuring for unused state variable - Add i18n: replace hardcoded English strings with translation keys - Extract shared utility: create core/model_config.py to eliminate duplicate model/thinking budget parsing code (DRY violation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): clear stale logs when starting new follow-up review When starting a second follow-up review, the UI was showing the previous review's completed phase statuses instead of fresh pending states. This happened because the logs state wasn't cleared when a new review started. Now clears the logs state when isReviewing transitions from false to true, ensuring fresh logs are fetched and displayed. * fix(security): address remaining command injection vulnerabilities Fixes HIGH and MEDIUM severity issues from PR review: Security fixes: - Remove shell: true from spawn() in mcp-handlers.ts checkCommandHealth and testCommandConnection to prevent shell metacharacter injection - Add command allowlist (npx, npm, node, python) and blocklist (bash, sh, cmd, powershell) to _validate_custom_mcp_server() in client.py - Fix type validation mismatch: 'url' -> 'http' to match downstream usage Quality fixes: - Add negative value validation for UTILITY_THINKING_BUDGET in model_config.py - Replace silent error swallowing with console.error in PRDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(pr-review): extract shared utilities and reduce complexity - Extract SDK stream processing into sdk_utils.py (~374 lines removed) - Callback-based architecture for message/thinking/error handling - Eliminates duplicate stream processing in 3+ reviewer modules - Extract category mapping into category_utils.py (~80 lines removed) - Unified CATEGORY_MAPPING dictionary - Single source of truth for severity/category translations - Refactor parallel_orchestrator_reviewer.py review() method - Split 380-line method into 165 lines + 8 focused helpers - Each extracted method under 50 lines for maintainability - Extracted: _prepare_context, _create_specialist_inputs, etc. - Remove unused ReviewCategory imports after extraction Total: ~454 lines of duplicate code eliminated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(security): address all remaining PR #424 review findings Backend security hardening (client.py): - Reject commands with path separators (/ or \) to prevent path traversal - Add dangerous interpreter flags blocklist (--eval, -e, -c, --exec) - Add pwsh (PowerShell Core) to DANGEROUS_COMMANDS Frontend defense-in-depth (mcp-handlers.ts): - Add SAFE_COMMANDS allowlist with path validation before spawn - Add OS-level timeout (15000ms) to testCommandConnection spawn Model config fix: - Treat UTILITY_THINKING_BUDGET=0 as "disable thinking" (same as empty) SDK stream processing improvements (sdk_utils.py): - Add try/except for stream-level and message-level errors - Add warning when multiple StructuredOutput blocks overwrite previous - Return error field in result dict for caller visibility Code consolidation: - Remove duplicate _CATEGORY_MAPPING from review_tools.py, use shared module - Remove unreachable 'best-practices' entry in category_utils.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pr-review): capture full summary content in synthesis logs Extended the log parsing patterns to capture markdown content that appears in the review summary. Previously, only header lines like "Summary:" were captured, but the actual content (markdown headers, bullet points, numbered lists, findings, file references) was discarded because it didn't match any pattern. Added patterns for: - Markdown headers (##, ###) - Bullet points and indented findings - Bold text lines - Numbered lists - File references - Additional summary fields (Is Follow-up, Resolved, etc.) * fix(pr-review): sync PR list status with detail view using overallStatus The PR list was computing status based only on HIGH/CRITICAL severity findings, while the PR detail used the overallStatus field from the backend. This caused inconsistent display where list showed "Ready to Merge" but detail showed "Changes Requested" for MEDIUM severity issues. Fixed by using overallStatus as the source of truth in both: - PRList.tsx: hasBlockingFindings prop computation - usePRFiltering.ts: getPRComputedStatus function * fix(security): address follow-up review findings round 2 Backend security (client.py): - Expand DANGEROUS_FLAGS to include: -m (Python module), -p (Python eval+print), --print, --input-type=module, --experimental-loader, --require, -r Frontend defense-in-depth (mcp-handlers.ts): - Add DANGEROUS_FLAGS set mirroring backend - Add areArgsSafe() function to validate args - Check args in both checkCommandHealth and testCommandConnection before spawn SDK stream error handling: - Add error field check in parallel_orchestrator_reviewer.py - Add error field check in parallel_followup_reviewer.py - Raise RuntimeError on stream failure instead of silently continuing Model config: - Add debug log when UTILITY_THINKING_BUDGET=0 disables thinking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -328,6 +337,7 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
create-release:
|
||||
@@ -350,12 +360,12 @@ jobs:
|
||||
- name: Flatten and validate artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.yml" \) -exec cp {} release-assets/ \;
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -413,7 +423,7 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: "CLA Assistant"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
name: CLA Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: |
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' ||
|
||||
github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: '.github/cla-signatures.json'
|
||||
path-to-document: 'https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md'
|
||||
branch: 'main'
|
||||
# Allowlist for bots and automation
|
||||
allowlist: 'dependabot[bot],github-actions[bot],renovate[bot],coderabbitai[bot]'
|
||||
# Custom messages
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can accept your PR, you need to sign our Contributor License Agreement (CLA).
|
||||
|
||||
**To sign the CLA**, please comment on this PR with exactly:
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
You can read the full CLA here: [CLA.md](https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md)
|
||||
|
||||
---
|
||||
**Why do we need a CLA?**
|
||||
|
||||
Auto Claude is licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
custom-allsigned-prcomment: |
|
||||
All contributors have signed the CLA. Thank you!
|
||||
lock-pullrequest-aftermerge: false
|
||||
suggest-recheck: true
|
||||
@@ -266,6 +266,19 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
"tools": BASE_READ_TOOLS
|
||||
+ WEB_TOOLS, # Read-only for parallel followup reviewer
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -337,10 +350,46 @@ def get_agent_config(agent_type: str) -> dict:
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def _map_mcp_server_name(
|
||||
name: str, custom_server_ids: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Map user-friendly MCP server names to internal identifiers.
|
||||
Also accepts custom server IDs directly.
|
||||
|
||||
Args:
|
||||
name: User-provided MCP server name
|
||||
custom_server_ids: List of custom server IDs to accept as-is
|
||||
|
||||
Returns:
|
||||
Internal server identifier or None if not recognized
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
mappings = {
|
||||
"context7": "context7",
|
||||
"graphiti-memory": "graphiti",
|
||||
"graphiti": "graphiti",
|
||||
"linear": "linear",
|
||||
"electron": "electron",
|
||||
"puppeteer": "puppeteer",
|
||||
"auto-claude": "auto-claude",
|
||||
}
|
||||
# Check if it's a known mapping
|
||||
mapped = mappings.get(name.lower().strip())
|
||||
if mapped:
|
||||
return mapped
|
||||
# Check if it's a custom server ID (accept as-is)
|
||||
if custom_server_ids and name in custom_server_ids:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
@@ -349,11 +398,16 @@ def get_required_mcp_servers(
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
- Respects per-project MCP config overrides from .auto-claude/.env
|
||||
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
|
||||
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
@@ -361,28 +415,80 @@ def get_required_mcp_servers(
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Load per-project config (or use defaults)
|
||||
if mcp_config is None:
|
||||
mcp_config = {}
|
||||
|
||||
# Filter context7 if explicitly disabled by project config
|
||||
if "context7" in servers:
|
||||
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
|
||||
if str(context7_enabled).lower() == "false":
|
||||
servers = [s for s in servers if s != "context7"]
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
servers.append("linear")
|
||||
# Also check per-project LINEAR_MCP_ENABLED override
|
||||
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
|
||||
if str(linear_mcp_enabled).lower() != "false":
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type and config
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
# Check per-project overrides (default false for both)
|
||||
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
|
||||
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
|
||||
|
||||
# Electron: enabled by project config OR global env var
|
||||
if is_electron and (
|
||||
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
|
||||
):
|
||||
servers.append("electron")
|
||||
# Puppeteer: enabled by project config (no global env var)
|
||||
elif is_web_frontend and not is_electron:
|
||||
servers.append("puppeteer")
|
||||
if str(puppeteer_enabled).lower() == "true":
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
# ========== Apply per-agent MCP overrides ==========
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
add_key = f"AGENT_MCP_{agent_type}_ADD"
|
||||
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
|
||||
|
||||
# Extract custom server IDs for mapping (allows custom servers to be recognized)
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
|
||||
|
||||
# Process additions
|
||||
if add_key in mcp_config:
|
||||
additions = [
|
||||
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in additions:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped not in servers:
|
||||
servers.append(mapped)
|
||||
|
||||
# Process removals (but never remove auto-claude)
|
||||
if remove_key in mcp_config:
|
||||
removals = [
|
||||
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in removals:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
|
||||
servers = [s for s in servers if s != mapped]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
@@ -46,6 +47,7 @@ def get_allowed_tools(
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
@@ -64,6 +66,7 @@ def get_allowed_tools(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
|
||||
@@ -186,9 +186,15 @@ Fixes #N (if applicable)"""
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude_haiku(prompt: str) -> str:
|
||||
"""Call Claude Haiku with low thinking for fast commit message generation."""
|
||||
async def _call_claude(prompt: str) -> str:
|
||||
"""Call Claude for commit message generation.
|
||||
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
"""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
@@ -202,11 +208,18 @@ async def _call_claude_haiku(prompt: str) -> str:
|
||||
logger.warning("core.simple_client not available")
|
||||
return ""
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Commit message using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
model=model,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -284,11 +297,9 @@ def generate_commit_message_sync(
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(
|
||||
lambda: asyncio.run(_call_claude_haiku(prompt))
|
||||
).result()
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
@@ -350,7 +361,7 @@ async def generate_commit_message(
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude_haiku(prompt)
|
||||
result = await _call_claude(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,9 +13,12 @@ single source of truth for phase-aware tool and MCP server configuration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
@@ -35,6 +38,245 @@ from prompts_pkg.project_context import detect_project_capabilities, load_projec
|
||||
from security import bash_security_hook
|
||||
|
||||
|
||||
def _validate_custom_mcp_server(server: dict) -> bool:
|
||||
"""
|
||||
Validate a custom MCP server configuration for security.
|
||||
|
||||
Ensures only expected fields with valid types are present.
|
||||
Rejects configurations that could lead to command injection.
|
||||
|
||||
Args:
|
||||
server: Dict representing a custom MCP server configuration
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not isinstance(server, dict):
|
||||
return False
|
||||
|
||||
# Required fields
|
||||
required_fields = {"id", "name", "type"}
|
||||
if not all(field in server for field in required_fields):
|
||||
logger.warning(
|
||||
f"Custom MCP server missing required fields: {required_fields - server.keys()}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate field types
|
||||
if not isinstance(server.get("id"), str) or not server["id"]:
|
||||
return False
|
||||
if not isinstance(server.get("name"), str) or not server["name"]:
|
||||
return False
|
||||
# FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage
|
||||
if server.get("type") not in ("command", "http"):
|
||||
logger.warning(f"Invalid MCP server type: {server.get('type')}")
|
||||
return False
|
||||
|
||||
# Allowlist of safe executable commands for MCP servers
|
||||
# Only allow known package managers and interpreters - NO shell commands
|
||||
SAFE_COMMANDS = {
|
||||
"npx",
|
||||
"npm",
|
||||
"node",
|
||||
"python",
|
||||
"python3",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
|
||||
# Blocklist of dangerous shell commands that should never be allowed
|
||||
DANGEROUS_COMMANDS = {
|
||||
"bash",
|
||||
"sh",
|
||||
"cmd",
|
||||
"powershell",
|
||||
"pwsh", # PowerShell Core
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
}
|
||||
|
||||
# Dangerous interpreter flags that allow arbitrary code execution
|
||||
# Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general
|
||||
DANGEROUS_FLAGS = {
|
||||
"--eval",
|
||||
"-e",
|
||||
"-c",
|
||||
"--exec",
|
||||
"-m", # Python module execution
|
||||
"-p", # Python eval+print
|
||||
"--print", # Node.js print
|
||||
"--input-type=module", # Node.js ES module mode
|
||||
"--experimental-loader", # Node.js custom loaders
|
||||
"--require", # Node.js require injection
|
||||
"-r", # Node.js require shorthand
|
||||
}
|
||||
|
||||
# Type-specific validation
|
||||
if server["type"] == "command":
|
||||
if not isinstance(server.get("command"), str) or not server["command"]:
|
||||
logger.warning("Command-type MCP server missing 'command' field")
|
||||
return False
|
||||
|
||||
# SECURITY FIX: Validate command is in safe list and not in dangerous list
|
||||
command = server.get("command", "")
|
||||
|
||||
# Reject paths - commands must be bare names only (no / or \)
|
||||
# This prevents path traversal like '/custom/malicious' or './evil'
|
||||
if "/" in command or "\\" in command:
|
||||
logger.warning(
|
||||
f"Rejected command with path in MCP server: {command}. "
|
||||
f"Commands must be bare names without path separators."
|
||||
)
|
||||
return False
|
||||
|
||||
if command in DANGEROUS_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous command in MCP server: {command}. "
|
||||
f"Shell commands are not allowed for security reasons."
|
||||
)
|
||||
return False
|
||||
|
||||
if command not in SAFE_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected unknown command in MCP server: {command}. "
|
||||
f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate args is a list of strings if present
|
||||
if "args" in server:
|
||||
if not isinstance(server["args"], list):
|
||||
return False
|
||||
if not all(isinstance(arg, str) for arg in server["args"]):
|
||||
return False
|
||||
# Check for dangerous interpreter flags that allow code execution
|
||||
for arg in server["args"]:
|
||||
if arg in DANGEROUS_FLAGS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous flag '{arg}' in MCP server args. "
|
||||
f"Interpreter code execution flags are not allowed."
|
||||
)
|
||||
return False
|
||||
elif server["type"] == "http":
|
||||
if not isinstance(server.get("url"), str) or not server["url"]:
|
||||
logger.warning("HTTP-type MCP server missing 'url' field")
|
||||
return False
|
||||
# Validate headers is a dict of strings if present
|
||||
if "headers" in server:
|
||||
if not isinstance(server["headers"], dict):
|
||||
return False
|
||||
if not all(
|
||||
isinstance(k, str) and isinstance(v, str)
|
||||
for k, v in server["headers"].items()
|
||||
):
|
||||
return False
|
||||
|
||||
# Optional description must be string if present
|
||||
if "description" in server and not isinstance(server.get("description"), str):
|
||||
return False
|
||||
|
||||
# Reject any unexpected fields that could be exploited
|
||||
allowed_fields = {
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"command",
|
||||
"args",
|
||||
"url",
|
||||
"headers",
|
||||
"description",
|
||||
}
|
||||
unexpected_fields = set(server.keys()) - allowed_fields
|
||||
if unexpected_fields:
|
||||
logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def load_project_mcp_config(project_dir: Path) -> dict:
|
||||
"""
|
||||
Load MCP configuration from project's .auto-claude/.env file.
|
||||
|
||||
Returns a dict of MCP-related env vars:
|
||||
- CONTEXT7_ENABLED (default: true)
|
||||
- LINEAR_MCP_ENABLED (default: true)
|
||||
- ELECTRON_MCP_ENABLED (default: false)
|
||||
- PUPPETEER_MCP_ENABLED (default: false)
|
||||
- AGENT_MCP_<agent>_ADD (per-agent MCP additions)
|
||||
- AGENT_MCP_<agent>_REMOVE (per-agent MCP removals)
|
||||
- CUSTOM_MCP_SERVERS (JSON array of custom server configs)
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON)
|
||||
"""
|
||||
env_path = project_dir / ".auto-claude" / ".env"
|
||||
if not env_path.exists():
|
||||
return {}
|
||||
|
||||
config = {}
|
||||
mcp_keys = {
|
||||
"CONTEXT7_ENABLED",
|
||||
"LINEAR_MCP_ENABLED",
|
||||
"ELECTRON_MCP_ENABLED",
|
||||
"PUPPETEER_MCP_ENABLED",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
# Include global MCP toggles
|
||||
if key in mcp_keys:
|
||||
config[key] = value
|
||||
# Include per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
|
||||
elif key.startswith("AGENT_MCP_"):
|
||||
config[key] = value
|
||||
# Include custom MCP servers (parse JSON with schema validation)
|
||||
elif key == "CUSTOM_MCP_SERVERS":
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if not isinstance(parsed, list):
|
||||
logger.warning(
|
||||
"CUSTOM_MCP_SERVERS must be a JSON array"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
else:
|
||||
# Validate each server and filter out invalid ones
|
||||
valid_servers = []
|
||||
for i, server in enumerate(parsed):
|
||||
if _validate_custom_mcp_server(server):
|
||||
valid_servers.append(server)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Skipping invalid custom MCP server at index {i}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = valid_servers
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load project MCP config from {env_path}: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def is_graphiti_mcp_enabled() -> bool:
|
||||
"""
|
||||
Check if Graphiti MCP server integration is enabled.
|
||||
@@ -97,6 +339,7 @@ def create_client(
|
||||
agent_type: str = "coder",
|
||||
max_thinking_tokens: int | None = None,
|
||||
output_format: dict | None = None,
|
||||
agents: dict | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
@@ -119,6 +362,10 @@ def create_client(
|
||||
output_format: Optional structured output format for validated JSON responses.
|
||||
Use {"type": "json_schema", "schema": Model.model_json_schema()}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
agents: Optional dict of subagent definitions for SDK parallel execution.
|
||||
Format: {"agent-name": {"description": "...", "prompt": "...",
|
||||
"tools": [...], "model": "inherit"}}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
@@ -152,20 +399,27 @@ def create_client(
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
# Load per-project MCP configuration from .auto-claude/.env
|
||||
mcp_config = load_project_mcp_config(project_dir)
|
||||
|
||||
# Get allowed tools using phase-aware configuration
|
||||
# This respects AGENT_CONFIGS and only includes tools the agent needs
|
||||
# Also respects per-project MCP configuration
|
||||
allowed_tools_list = get_allowed_tools(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
|
||||
# Get required MCP servers for this agent type
|
||||
# This is the key optimization - only start servers the agent needs
|
||||
# Now also respects per-project MCP configuration
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
|
||||
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
|
||||
@@ -323,6 +577,30 @@ def create_client(
|
||||
if auto_claude_mcp_server:
|
||||
mcp_servers["auto-claude"] = auto_claude_mcp_server
|
||||
|
||||
# Add custom MCP servers from project config
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
for custom in custom_servers:
|
||||
server_id = custom.get("id")
|
||||
if not server_id:
|
||||
continue
|
||||
# Only include if agent has it in their effective server list
|
||||
if server_id not in required_servers:
|
||||
continue
|
||||
server_type = custom.get("type", "command")
|
||||
if server_type == "command":
|
||||
mcp_servers[server_id] = {
|
||||
"command": custom.get("command", "npx"),
|
||||
"args": custom.get("args", []),
|
||||
}
|
||||
elif server_type == "http":
|
||||
server_config = {
|
||||
"type": "http",
|
||||
"url": custom.get("url", ""),
|
||||
}
|
||||
if custom.get("headers"):
|
||||
server_config["headers"] = custom["headers"]
|
||||
mcp_servers[server_id] = server_config
|
||||
|
||||
# Build system prompt
|
||||
base_prompt = (
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
@@ -370,4 +648,9 @@ def create_client(
|
||||
if output_format:
|
||||
options_kwargs["output_format"] = output_format
|
||||
|
||||
# Add subagent definitions if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
if agents:
|
||||
options_kwargs["agents"] = agents
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Model Configuration Utilities
|
||||
==============================
|
||||
|
||||
Shared utilities for reading and parsing model configuration from environment variables.
|
||||
Used by both commit_message.py and merge resolver.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default model for utility operations (commit messages, merge resolution)
|
||||
DEFAULT_UTILITY_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
|
||||
def get_utility_model_config(
|
||||
default_model: str = DEFAULT_UTILITY_MODEL,
|
||||
) -> tuple[str, int | None]:
|
||||
"""
|
||||
Get utility model configuration from environment variables.
|
||||
|
||||
Reads UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET from environment,
|
||||
with sensible defaults and validation.
|
||||
|
||||
Args:
|
||||
default_model: Default model ID to use if UTILITY_MODEL_ID not set
|
||||
|
||||
Returns:
|
||||
Tuple of (model_id, thinking_budget) where thinking_budget is None
|
||||
if extended thinking is disabled, or an int representing token budget
|
||||
"""
|
||||
model = os.environ.get("UTILITY_MODEL_ID", default_model)
|
||||
thinking_budget_str = os.environ.get("UTILITY_THINKING_BUDGET", "")
|
||||
|
||||
# Parse thinking budget: empty string = disabled (None), number = budget tokens
|
||||
# Note: 0 is treated as "disable thinking" (same as None) since 0 tokens is meaningless
|
||||
thinking_budget: int | None
|
||||
if not thinking_budget_str:
|
||||
# Empty string means "none" level - disable extended thinking
|
||||
thinking_budget = None
|
||||
else:
|
||||
try:
|
||||
parsed_budget = int(thinking_budget_str)
|
||||
# Validate positive values - 0 or negative are invalid
|
||||
# 0 would mean "thinking enabled but 0 tokens" which is meaningless
|
||||
if parsed_budget <= 0:
|
||||
if parsed_budget == 0:
|
||||
# Zero means disable thinking (same as empty string)
|
||||
logger.debug(
|
||||
"UTILITY_THINKING_BUDGET=0 interpreted as 'disable thinking'"
|
||||
)
|
||||
thinking_budget = None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Negative UTILITY_THINKING_BUDGET value '{thinking_budget_str}' not allowed, using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
else:
|
||||
thinking_budget = parsed_budget
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Invalid UTILITY_THINKING_BUDGET value '{thinking_budget_str}', using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
|
||||
return model, thinking_budget
|
||||
@@ -26,12 +26,16 @@ def create_claude_resolver() -> AIResolver:
|
||||
Create an AIResolver configured to use Claude via the Agent SDK.
|
||||
|
||||
Uses the same OAuth token pattern as the rest of the auto-claude framework.
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
|
||||
Returns:
|
||||
Configured AIResolver instance
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
from .resolver import AIResolver
|
||||
|
||||
@@ -48,6 +52,13 @@ def create_claude_resolver() -> AIResolver:
|
||||
logger.warning("core.simple_client not available, AI resolution unavailable")
|
||||
return AIResolver()
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Merge resolver using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
def call_claude(system: str, user: str) -> str:
|
||||
"""Call Claude using the Agent SDK for merge resolution."""
|
||||
|
||||
@@ -55,8 +66,9 @@ def create_claude_resolver() -> AIResolver:
|
||||
# Create a minimal client for merge resolution
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model="sonnet",
|
||||
model=model,
|
||||
system_prompt=system,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Codebase Fit Review Agent
|
||||
|
||||
You are a focused codebase fit review agent. You have been spawned by the orchestrating agent to verify that new code fits well within the existing codebase, follows established patterns, and doesn't reinvent existing functionality.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
|
||||
|
||||
## Codebase Fit Focus Areas
|
||||
|
||||
### 1. Naming Conventions
|
||||
- **Inconsistent Naming**: Using `camelCase` when project uses `snake_case`
|
||||
- **Different Terminology**: Using `user` when codebase uses `account`
|
||||
- **Abbreviation Mismatch**: Using `usr` when codebase spells out `user`
|
||||
- **File Naming**: `MyComponent.tsx` vs `my-component.tsx` vs `myComponent.tsx`
|
||||
- **Directory Structure**: Placing files in wrong directories
|
||||
|
||||
### 2. Pattern Adherence
|
||||
- **Framework Patterns**: Not following React hooks pattern, Django views pattern, etc.
|
||||
- **Project Patterns**: Not following established error handling, logging, or API patterns
|
||||
- **Architectural Patterns**: Violating layer separation (e.g., business logic in controllers)
|
||||
- **State Management**: Using different state management approach than established
|
||||
- **Configuration Patterns**: Different config file format or location
|
||||
|
||||
### 3. Ecosystem Fit
|
||||
- **Reinventing Utilities**: Writing new helper when similar one exists
|
||||
- **Duplicate Functionality**: Adding code that duplicates existing implementation
|
||||
- **Ignoring Shared Code**: Not using established shared components/utilities
|
||||
- **Wrong Abstraction Level**: Creating too specific or too generic solutions
|
||||
- **Missing Integration**: Not integrating with existing systems (logging, metrics, etc.)
|
||||
|
||||
### 4. Architectural Consistency
|
||||
- **Layer Violations**: Calling database directly from UI components
|
||||
- **Dependency Direction**: Wrong dependency direction between modules
|
||||
- **Module Boundaries**: Crossing module boundaries inappropriately
|
||||
- **API Contracts**: Breaking established API patterns
|
||||
- **Data Flow**: Different data flow pattern than established
|
||||
|
||||
### 5. Monolithic File Detection
|
||||
- **Large Files**: Files exceeding 500 lines (should be split)
|
||||
- **God Objects**: Classes/modules doing too many unrelated things
|
||||
- **Mixed Concerns**: UI, business logic, and data access in same file
|
||||
- **Excessive Exports**: Files exporting too many unrelated items
|
||||
|
||||
### 6. Import/Dependency Patterns
|
||||
- **Import Style**: Relative vs absolute imports, import grouping
|
||||
- **Circular Dependencies**: Creating import cycles
|
||||
- **Unused Imports**: Adding imports that aren't used
|
||||
- **Dependency Injection**: Not following DI patterns when established
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### High Confidence Only
|
||||
- Only report findings with **>80% confidence**
|
||||
- Verify pattern exists in codebase before flagging deviation
|
||||
- Consider if "inconsistency" might be intentional improvement
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Architectural violation that will cause maintenance problems
|
||||
- Example: Tight coupling that makes testing impossible
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Significant deviation from established patterns
|
||||
- Example: Reimplementing existing utility, wrong directory structure
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Inconsistency that affects maintainability
|
||||
- Example: Different naming convention, unused existing helper
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor convention deviation
|
||||
- Example: Different import ordering, minor naming variation
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Check Before Reporting
|
||||
Before flagging a "should use existing utility" issue:
|
||||
1. Verify the existing utility actually does what the new code needs
|
||||
2. Check if existing utility has the right signature/behavior
|
||||
3. Consider if the new implementation is intentionally different
|
||||
|
||||
## Code Patterns to Flag
|
||||
|
||||
### Reinventing Existing Utilities
|
||||
```javascript
|
||||
// If codebase has: src/utils/format.ts with formatDate()
|
||||
// Flag this:
|
||||
function formatDateString(date) {
|
||||
return `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
|
||||
}
|
||||
// Should use: import { formatDate } from '@/utils/format';
|
||||
```
|
||||
|
||||
### Naming Convention Violations
|
||||
```python
|
||||
# If codebase uses snake_case:
|
||||
def getUserById(user_id): # Should be: get_user_by_id
|
||||
...
|
||||
|
||||
# If codebase uses specific terminology:
|
||||
class Customer: # Should be: User (if that's the codebase term)
|
||||
...
|
||||
```
|
||||
|
||||
### Architectural Violations
|
||||
```typescript
|
||||
// If codebase separates concerns:
|
||||
// In UI component:
|
||||
const users = await db.query('SELECT * FROM users'); // BAD
|
||||
// Should use: const users = await userService.getAll();
|
||||
|
||||
// If codebase has established API patterns:
|
||||
app.get('/user', ...) // BAD: singular
|
||||
app.get('/users', ...) // GOOD: matches codebase plural pattern
|
||||
```
|
||||
|
||||
### Monolithic Files
|
||||
```typescript
|
||||
// File with 800 lines doing:
|
||||
// - API handlers
|
||||
// - Business logic
|
||||
// - Database queries
|
||||
// - Utility functions
|
||||
// Should be split into separate files per concern
|
||||
```
|
||||
|
||||
### Import Pattern Violations
|
||||
```javascript
|
||||
// If codebase uses absolute imports:
|
||||
import { User } from '../../../models/user'; // BAD
|
||||
import { User } from '@/models/user'; // GOOD
|
||||
|
||||
// If codebase groups imports:
|
||||
// 1. External packages
|
||||
// 2. Internal modules
|
||||
// 3. Relative imports
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide findings in JSON format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"file": "src/components/UserCard.tsx",
|
||||
"line": 15,
|
||||
"title": "Reinventing existing date formatting utility",
|
||||
"description": "This file implements custom date formatting, but the codebase already has `formatDate()` in `src/utils/date.ts` that does the same thing.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "high",
|
||||
"existing_code": "src/utils/date.ts:formatDate()",
|
||||
"suggested_fix": "Replace custom implementation with: import { formatDate } from '@/utils/date';",
|
||||
"confidence": 92
|
||||
},
|
||||
{
|
||||
"file": "src/api/customers.ts",
|
||||
"line": 1,
|
||||
"title": "File uses 'customer' but codebase uses 'user'",
|
||||
"description": "This file uses 'customer' terminology but the rest of the codebase consistently uses 'user'. This creates confusion and makes search/navigation harder.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "medium",
|
||||
"codebase_pattern": "src/models/user.ts, src/api/users.ts, src/services/userService.ts",
|
||||
"suggested_fix": "Rename to use 'user' terminology to match codebase conventions",
|
||||
"confidence": 88
|
||||
},
|
||||
{
|
||||
"file": "src/services/orderProcessor.ts",
|
||||
"line": 1,
|
||||
"title": "Monolithic file exceeds 500 lines",
|
||||
"description": "This file is 847 lines and contains order validation, payment processing, inventory management, and notification sending. Each should be separate.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "high",
|
||||
"current_lines": 847,
|
||||
"suggested_fix": "Split into: orderValidator.ts, paymentProcessor.ts, inventoryManager.ts, notificationService.ts",
|
||||
"confidence": 95
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Verify Existing Code**: Before flagging "use existing", verify the existing code actually fits
|
||||
2. **Check Codebase Patterns**: Look at multiple files to confirm a pattern exists
|
||||
3. **Consider Evolution**: Sometimes new code is intentionally better than existing patterns
|
||||
4. **Respect Domain Boundaries**: Different domains might have different conventions
|
||||
5. **Focus on Changed Files**: Don't audit the entire codebase, focus on new/modified code
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Security issues (handled by security agent)
|
||||
- Logic correctness (handled by logic agent)
|
||||
- Code quality metrics (handled by quality agent)
|
||||
- Personal preferences about patterns
|
||||
- Style issues covered by linters
|
||||
- Test files that intentionally have different structure
|
||||
|
||||
## Codebase Analysis Tips
|
||||
|
||||
When analyzing codebase fit, look at:
|
||||
1. **Similar Files**: How are other similar files structured?
|
||||
2. **Shared Utilities**: What's in `utils/`, `helpers/`, `shared/`?
|
||||
3. **Naming Patterns**: What naming style do existing files use?
|
||||
4. **Directory Structure**: Where do similar files live?
|
||||
5. **Import Patterns**: How do other files import dependencies?
|
||||
|
||||
Focus on **codebase consistency** - new code fitting seamlessly with existing code.
|
||||
@@ -103,14 +103,16 @@ For important unaddressed comments, create a finding:
|
||||
|
||||
### Phase 4: Merge Readiness Assessment
|
||||
|
||||
Determine the verdict based on:
|
||||
Determine the verdict based on (Strict Quality Gates - MEDIUM also blocks):
|
||||
|
||||
| Verdict | Criteria |
|
||||
|---------|----------|
|
||||
| **READY_TO_MERGE** | All previous findings resolved, no new critical/high issues, tests pass |
|
||||
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new medium/low issues remain |
|
||||
| **NEEDS_REVISION** | Some high-severity issues unresolved or new high issues found |
|
||||
| **BLOCKED** | Critical issues unresolved or new critical issues introduced |
|
||||
| **READY_TO_MERGE** | All previous findings resolved, no new issues, tests pass |
|
||||
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new LOW severity suggestions remain |
|
||||
| **NEEDS_REVISION** | HIGH or MEDIUM severity issues unresolved, or new HIGH/MEDIUM issues found |
|
||||
| **BLOCKED** | CRITICAL issues unresolved or new CRITICAL issues introduced |
|
||||
|
||||
Note: Both HIGH and MEDIUM block merge - AI fixes quickly, so be strict about quality.
|
||||
|
||||
## Output Format
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Comment Analysis Agent (Follow-up)
|
||||
|
||||
You are a specialized agent for analyzing comments and reviews posted since the last PR review. You have been spawned by the orchestrating agent to process feedback from contributors and AI tools.
|
||||
|
||||
## Your Mission
|
||||
|
||||
1. Analyze contributor comments for questions and concerns
|
||||
2. Triage AI tool reviews (CodeRabbit, Cursor, Gemini, etc.)
|
||||
3. Identify issues that need addressing before merge
|
||||
4. Flag unanswered questions
|
||||
|
||||
## Comment Sources
|
||||
|
||||
### Contributor Comments
|
||||
- Direct questions about implementation
|
||||
- Concerns about approach
|
||||
- Suggestions for improvement
|
||||
- Approval or rejection signals
|
||||
|
||||
### AI Tool Reviews
|
||||
Common AI reviewers you'll encounter:
|
||||
- **CodeRabbit**: Comprehensive code analysis
|
||||
- **Cursor**: AI-assisted review comments
|
||||
- **Gemini Code Assist**: Google's code reviewer
|
||||
- **GitHub Copilot**: Inline suggestions
|
||||
- **Greptile**: Codebase-aware analysis
|
||||
- **SonarCloud**: Static analysis findings
|
||||
- **Snyk**: Security scanning results
|
||||
|
||||
## Analysis Framework
|
||||
|
||||
### For Each Comment
|
||||
|
||||
1. **Identify the author**
|
||||
- Is this a human contributor or AI bot?
|
||||
- What's their role (maintainer, contributor, reviewer)?
|
||||
|
||||
2. **Classify sentiment**
|
||||
- question: Asking for clarification
|
||||
- concern: Expressing worry about approach
|
||||
- suggestion: Proposing alternative
|
||||
- praise: Positive feedback
|
||||
- neutral: Informational only
|
||||
|
||||
3. **Assess urgency**
|
||||
- Does this block merge?
|
||||
- Is a response required?
|
||||
- What action is needed?
|
||||
|
||||
4. **Extract actionable items**
|
||||
- What specific change is requested?
|
||||
- Is the concern valid?
|
||||
- How should it be addressed?
|
||||
|
||||
## Triage AI Tool Comments
|
||||
|
||||
### Critical (Must Address)
|
||||
- Security vulnerabilities flagged
|
||||
- Data loss risks
|
||||
- Authentication bypasses
|
||||
- Injection vulnerabilities
|
||||
|
||||
### Important (Should Address)
|
||||
- Logic errors in core paths
|
||||
- Missing error handling
|
||||
- Race conditions
|
||||
- Resource leaks
|
||||
|
||||
### Nice-to-Have (Consider)
|
||||
- Code style suggestions
|
||||
- Performance optimizations
|
||||
- Documentation improvements
|
||||
|
||||
### False Positive (Dismiss)
|
||||
- Incorrect analysis
|
||||
- Not applicable to this context
|
||||
- Already addressed
|
||||
- Stylistic preferences
|
||||
|
||||
## Output Format
|
||||
|
||||
### Comment Analyses
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"comment_id": "IC-12345",
|
||||
"author": "maintainer-jane",
|
||||
"is_ai_bot": false,
|
||||
"requires_response": true,
|
||||
"sentiment": "question",
|
||||
"summary": "Asks why async/await was chosen over callbacks",
|
||||
"action_needed": "Respond explaining the async choice for better error handling"
|
||||
},
|
||||
{
|
||||
"comment_id": "RC-67890",
|
||||
"author": "coderabbitai[bot]",
|
||||
"is_ai_bot": true,
|
||||
"requires_response": false,
|
||||
"sentiment": "suggestion",
|
||||
"summary": "Suggests using optional chaining for null safety",
|
||||
"action_needed": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Comment Findings (Issues from Comments)
|
||||
|
||||
When AI tools or contributors identify real issues:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "CMT-001",
|
||||
"file": "src/api/handler.py",
|
||||
"line": 89,
|
||||
"title": "Unhandled exception in error path (from CodeRabbit)",
|
||||
"description": "CodeRabbit correctly identified that the except block at line 89 catches Exception but doesn't log or handle it properly.",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
"confidence": 0.85,
|
||||
"suggested_fix": "Add proper logging and re-raise or handle the exception appropriately",
|
||||
"fixable": true,
|
||||
"source_agent": "comment-analyzer",
|
||||
"related_to_previous": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Prioritization Rules
|
||||
|
||||
1. **Maintainer comments** > Contributor comments > AI bot comments
|
||||
2. **Questions from humans** always require response
|
||||
3. **Security issues from AI** should be verified and escalated
|
||||
4. **Repeated concerns** (same issue from multiple sources) are higher priority
|
||||
|
||||
## What to Flag
|
||||
|
||||
### Must Flag
|
||||
- Unanswered questions from maintainers
|
||||
- Unaddressed security findings from AI tools
|
||||
- Explicit change requests not yet implemented
|
||||
- Blocking concerns from reviewers
|
||||
|
||||
### Should Flag
|
||||
- Valid suggestions not yet addressed
|
||||
- Questions about implementation approach
|
||||
- Concerns about test coverage
|
||||
|
||||
### Can Skip
|
||||
- Resolved discussions
|
||||
- Acknowledged but deferred items
|
||||
- Style-only suggestions
|
||||
- Clearly false positive AI findings
|
||||
|
||||
## Identifying AI Bots
|
||||
|
||||
Common bot patterns:
|
||||
- `*[bot]` suffix (e.g., `coderabbitai[bot]`)
|
||||
- `*-bot` suffix
|
||||
- Known bot names: dependabot, renovate, snyk-bot, sonarcloud
|
||||
- Automated review format (structured markdown)
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Humans first**: Prioritize human feedback over AI suggestions
|
||||
2. **Context matters**: Consider the discussion thread, not just individual comments
|
||||
3. **Don't duplicate**: If an issue is already in previous findings, reference it
|
||||
4. **Be constructive**: Extract actionable items, not just concerns
|
||||
5. **Verify AI findings**: AI tools can be wrong - assess validity
|
||||
|
||||
## Sample Workflow
|
||||
|
||||
1. Collect all comments since last review timestamp
|
||||
2. Separate by source (contributor vs AI bot)
|
||||
3. For each contributor comment:
|
||||
- Classify sentiment and urgency
|
||||
- Check if response/action is needed
|
||||
4. For each AI review:
|
||||
- Triage by severity
|
||||
- Verify if finding is valid
|
||||
- Check if already addressed in new code
|
||||
5. Generate comment_analyses and comment_findings lists
|
||||
@@ -0,0 +1,162 @@
|
||||
# New Code Review Agent (Follow-up)
|
||||
|
||||
You are a specialized agent for reviewing new code added since the last PR review. You have been spawned by the orchestrating agent to identify issues in recently added changes.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Review the incremental diff for:
|
||||
1. Security vulnerabilities
|
||||
2. Logic errors and edge cases
|
||||
3. Code quality issues
|
||||
4. Potential regressions
|
||||
5. Incomplete implementations
|
||||
|
||||
## Focus Areas
|
||||
|
||||
Since this is a follow-up review, focus on:
|
||||
- **New code only**: Don't re-review unchanged code
|
||||
- **Fix quality**: Are the fixes implemented correctly?
|
||||
- **Regressions**: Did fixes break other things?
|
||||
- **Incomplete work**: Are there TODOs or unfinished sections?
|
||||
|
||||
## Review Categories
|
||||
|
||||
### Security (category: "security")
|
||||
- New injection vulnerabilities (SQL, XSS, command)
|
||||
- Hardcoded secrets or credentials
|
||||
- Authentication/authorization gaps
|
||||
- Insecure data handling
|
||||
|
||||
### Logic (category: "logic")
|
||||
- Off-by-one errors
|
||||
- Null/undefined handling
|
||||
- Race conditions
|
||||
- Incorrect boundary checks
|
||||
- State management issues
|
||||
|
||||
### Quality (category: "quality")
|
||||
- Error handling gaps
|
||||
- Resource leaks
|
||||
- Performance anti-patterns
|
||||
- Code duplication
|
||||
|
||||
### Regression (category: "regression")
|
||||
- Fixes that break existing behavior
|
||||
- Removed functionality without replacement
|
||||
- Changed APIs without updating callers
|
||||
- Tests that no longer pass
|
||||
|
||||
### Incomplete Fix (category: "incomplete_fix")
|
||||
- Partial implementations
|
||||
- TODO comments left in code
|
||||
- Error paths not handled
|
||||
- Missing test coverage for fix
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
### CRITICAL
|
||||
- Security vulnerabilities exploitable in production
|
||||
- Data corruption or loss risks
|
||||
- Complete feature breakage
|
||||
|
||||
### HIGH
|
||||
- Security issues requiring specific conditions
|
||||
- Logic errors affecting core functionality
|
||||
- Regressions in important features
|
||||
|
||||
### MEDIUM
|
||||
- Code quality issues affecting maintainability
|
||||
- Minor logic issues in edge cases
|
||||
- Missing error handling
|
||||
|
||||
### LOW
|
||||
- Style inconsistencies
|
||||
- Minor optimizations
|
||||
- Documentation gaps
|
||||
|
||||
## Confidence Scoring
|
||||
|
||||
Rate confidence (0.0-1.0) based on:
|
||||
- **>0.9**: Obvious, verifiable issue
|
||||
- **0.8-0.9**: High confidence with clear evidence
|
||||
- **0.7-0.8**: Likely issue but some uncertainty
|
||||
- **<0.7**: Possible issue, needs verification
|
||||
|
||||
Only report findings with confidence >0.7.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return findings in this structure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "NEW-001",
|
||||
"file": "src/auth/login.py",
|
||||
"line": 45,
|
||||
"end_line": 48,
|
||||
"title": "SQL injection in new login query",
|
||||
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"confidence": 0.95,
|
||||
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
"related_to_previous": null
|
||||
},
|
||||
{
|
||||
"id": "NEW-002",
|
||||
"file": "src/utils/parser.py",
|
||||
"line": 112,
|
||||
"title": "Fix introduced null pointer regression",
|
||||
"description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.",
|
||||
"category": "regression",
|
||||
"severity": "high",
|
||||
"confidence": 0.88,
|
||||
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
"related_to_previous": "LOGIC-003"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Issues in unchanged code (that's for initial review)
|
||||
- Style preferences without functional impact
|
||||
- Theoretical issues with <70% confidence
|
||||
- Duplicate findings (check if similar issue exists)
|
||||
- Issues already flagged by previous review
|
||||
|
||||
## Review Strategy
|
||||
|
||||
1. **Scan for red flags first**
|
||||
- eval(), exec(), dangerouslySetInnerHTML
|
||||
- Hardcoded passwords, API keys
|
||||
- SQL string concatenation
|
||||
- Shell command construction
|
||||
|
||||
2. **Check fix correctness**
|
||||
- Does the fix actually address the reported issue?
|
||||
- Are all code paths covered?
|
||||
- Are error cases handled?
|
||||
|
||||
3. **Look for collateral damage**
|
||||
- What else changed in the same files?
|
||||
- Could the fix affect other functionality?
|
||||
- Are there dependent changes needed?
|
||||
|
||||
4. **Verify completeness**
|
||||
- Are there TODOs left behind?
|
||||
- Is there test coverage for the changes?
|
||||
- Is documentation updated if needed?
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be focused**: Only review new changes, not the entire PR
|
||||
2. **Consider context**: Understand what the fix was trying to achieve
|
||||
3. **Be constructive**: Suggest fixes, not just problems
|
||||
4. **Avoid nitpicking**: Focus on functional issues
|
||||
5. **Link regressions**: If a fix caused a new issue, reference the original finding
|
||||
@@ -0,0 +1,141 @@
|
||||
# Parallel Follow-up Review Orchestrator
|
||||
|
||||
You are the orchestrating agent for follow-up PR reviews. Your job is to analyze incremental changes since the last review and coordinate specialized agents to verify resolution of previous findings and identify new issues.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Perform a focused, efficient follow-up review by:
|
||||
1. Analyzing the scope of changes since the last review
|
||||
2. Delegating to specialized agents based on what needs verification
|
||||
3. Synthesizing findings into a final merge verdict
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialist agents via the Task tool:
|
||||
|
||||
### 1. resolution-verifier
|
||||
**Use for**: Verifying whether previous findings have been addressed
|
||||
- Analyzes diffs to determine if issues are truly fixed
|
||||
- Checks for incomplete or incorrect fixes
|
||||
- Provides confidence scores for each resolution
|
||||
- **Invoke when**: There are previous findings to verify
|
||||
|
||||
### 2. new-code-reviewer
|
||||
**Use for**: Reviewing new code added since last review
|
||||
- Security issues in new code
|
||||
- Logic errors and edge cases
|
||||
- Code quality problems
|
||||
- Regressions that may have been introduced
|
||||
- **Invoke when**: There are substantial code changes (>50 lines diff)
|
||||
|
||||
### 3. comment-analyzer
|
||||
**Use for**: Processing contributor and AI tool feedback
|
||||
- Identifies unanswered questions from contributors
|
||||
- Triages AI tool comments (CodeRabbit, Cursor, Gemini, etc.)
|
||||
- Flags concerns that need addressing
|
||||
- **Invoke when**: There are comments or reviews since last review
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Analyze Scope
|
||||
Evaluate the follow-up context:
|
||||
- How many new commits?
|
||||
- How many files changed?
|
||||
- What's the diff size?
|
||||
- Are there previous findings to verify?
|
||||
- Are there new comments to process?
|
||||
|
||||
### Phase 2: Delegate to Agents
|
||||
Based on your analysis, invoke the appropriate agents:
|
||||
|
||||
**Always invoke** `resolution-verifier` if there are previous findings.
|
||||
|
||||
**Invoke** `new-code-reviewer` if:
|
||||
- Diff is substantial (>50 lines)
|
||||
- Changes touch security-sensitive areas
|
||||
- New files were added
|
||||
- Complex logic was modified
|
||||
|
||||
**Invoke** `comment-analyzer` if:
|
||||
- There are contributor comments since last review
|
||||
- There are AI tool reviews to triage
|
||||
- Questions remain unanswered
|
||||
|
||||
### Phase 3: Synthesize Results
|
||||
After agents complete:
|
||||
1. Combine resolution verifications
|
||||
2. Merge new findings (deduplicate if needed)
|
||||
3. Incorporate comment analysis
|
||||
4. Generate final verdict
|
||||
|
||||
## Verdict Guidelines
|
||||
|
||||
### READY_TO_MERGE
|
||||
- All previous findings verified as resolved
|
||||
- No new critical/high issues
|
||||
- No blocking concerns from comments
|
||||
- Contributor questions addressed
|
||||
|
||||
### MERGE_WITH_CHANGES
|
||||
- Previous findings resolved
|
||||
- Only LOW severity new issues (suggestions)
|
||||
- Optional polish items can be addressed post-merge
|
||||
|
||||
### NEEDS_REVISION (Strict Quality Gates)
|
||||
- HIGH or MEDIUM severity findings unresolved
|
||||
- New HIGH or MEDIUM severity issues introduced
|
||||
- Important contributor concerns unaddressed
|
||||
- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict)
|
||||
|
||||
### BLOCKED
|
||||
- CRITICAL findings remain unresolved
|
||||
- New CRITICAL issues introduced
|
||||
- Fundamental problems with the fix approach
|
||||
|
||||
## Cross-Validation
|
||||
|
||||
When multiple agents report on the same area:
|
||||
- **Agreement boosts confidence**: If resolution-verifier and new-code-reviewer both flag an issue, increase severity
|
||||
- **Conflicts need resolution**: If agents disagree, investigate and document your reasoning
|
||||
- **Track consensus**: Note which findings have cross-agent validation
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "Brief summary of what was analyzed",
|
||||
"agents_invoked": ["resolution-verifier", "new-code-reviewer"],
|
||||
"commits_analyzed": 5,
|
||||
"files_changed": 12,
|
||||
"resolution_verifications": [...],
|
||||
"new_findings": [...],
|
||||
"comment_analyses": [...],
|
||||
"comment_findings": [...],
|
||||
"agent_agreement": {
|
||||
"agreed_findings": [],
|
||||
"conflicting_findings": [],
|
||||
"resolution_notes": null
|
||||
},
|
||||
"verdict": "READY_TO_MERGE",
|
||||
"verdict_reasoning": "All 3 previous findings verified as resolved..."
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be efficient**: Follow-up reviews should be faster than initial reviews
|
||||
2. **Focus on changes**: Only review what changed since last review
|
||||
3. **Trust but verify**: Don't assume fixes are correct just because files changed
|
||||
4. **Acknowledge progress**: Recognize genuine effort to address feedback
|
||||
5. **Be specific**: Clearly state what blocks merge if verdict is not READY_TO_MERGE
|
||||
|
||||
## Context You Will Receive
|
||||
|
||||
- Previous review summary and findings
|
||||
- New commits since last review (SHAs, messages)
|
||||
- Diff of changes since last review
|
||||
- Files modified since last review
|
||||
- Contributor comments since last review
|
||||
- AI bot comments and reviews since last review
|
||||
@@ -0,0 +1,128 @@
|
||||
# Resolution Verification Agent
|
||||
|
||||
You are a specialized agent for verifying whether previous PR review findings have been addressed. You have been spawned by the orchestrating agent to analyze diffs and determine resolution status.
|
||||
|
||||
## Your Mission
|
||||
|
||||
For each previous finding, determine whether it has been:
|
||||
- **resolved**: The issue is fully fixed
|
||||
- **partially_resolved**: Some aspects fixed, but not complete
|
||||
- **unresolved**: The issue remains or wasn't addressed
|
||||
- **cant_verify**: Not enough information to determine status
|
||||
|
||||
## Verification Process
|
||||
|
||||
For each previous finding:
|
||||
|
||||
### 1. Locate the Issue
|
||||
- Find the file mentioned in the finding
|
||||
- Check if that file was modified in the new changes
|
||||
- If file wasn't modified, the finding is likely **unresolved**
|
||||
|
||||
### 2. Analyze the Fix
|
||||
If the file was modified:
|
||||
- Look at the specific lines mentioned
|
||||
- Check if the problematic code pattern is gone
|
||||
- Verify the fix actually addresses the root cause
|
||||
- Watch for "cosmetic" fixes that don't solve the problem
|
||||
|
||||
### 3. Check for Regressions
|
||||
- Did the fix introduce new problems?
|
||||
- Is the fix approach sound?
|
||||
- Are there edge cases the fix misses?
|
||||
|
||||
### 4. Assign Confidence
|
||||
Rate your confidence (0.0-1.0):
|
||||
- **>0.9**: Clear evidence of resolution/non-resolution
|
||||
- **0.7-0.9**: Strong indicators but some uncertainty
|
||||
- **0.5-0.7**: Mixed signals, moderate confidence
|
||||
- **<0.5**: Unclear, consider marking as cant_verify
|
||||
|
||||
## Resolution Criteria
|
||||
|
||||
### RESOLVED
|
||||
The finding is resolved when:
|
||||
- The problematic code is removed or fixed
|
||||
- The fix addresses the root cause (not just symptoms)
|
||||
- No new issues were introduced by the fix
|
||||
- Edge cases are handled appropriately
|
||||
|
||||
### PARTIALLY_RESOLVED
|
||||
Mark as partially resolved when:
|
||||
- Main issue is fixed but related problems remain
|
||||
- Fix works for common cases but misses edge cases
|
||||
- Some aspects addressed but not all
|
||||
- Workaround applied instead of proper fix
|
||||
|
||||
### UNRESOLVED
|
||||
Mark as unresolved when:
|
||||
- File wasn't modified at all
|
||||
- Code pattern still present
|
||||
- Fix attempt doesn't address the actual issue
|
||||
- Problem was misunderstood
|
||||
|
||||
### CANT_VERIFY
|
||||
Use when:
|
||||
- Diff doesn't include enough context
|
||||
- Issue requires runtime verification
|
||||
- Finding references external dependencies
|
||||
- Not enough information to determine
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
For each verification, provide:
|
||||
1. **What you looked for**: The code pattern or issue from the finding
|
||||
2. **What you found**: The current state in the diff
|
||||
3. **Why you concluded**: Your reasoning for the status
|
||||
|
||||
## Output Format
|
||||
|
||||
Return verifications in this structure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"status": "resolved",
|
||||
"confidence": 0.92,
|
||||
"evidence": "The SQL query at line 45 now uses parameterized queries instead of string concatenation. The fix properly escapes all user inputs.",
|
||||
"resolution_notes": "Changed from f-string to cursor.execute() with parameters"
|
||||
},
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"status": "partially_resolved",
|
||||
"confidence": 0.75,
|
||||
"evidence": "Error handling was added for the main path, but the fallback path at line 78 still lacks try-catch.",
|
||||
"resolution_notes": "Main function fixed, helper function still needs work"
|
||||
},
|
||||
{
|
||||
"finding_id": "LOGIC-003",
|
||||
"status": "unresolved",
|
||||
"confidence": 0.88,
|
||||
"evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.",
|
||||
"resolution_notes": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### False Positives (Marking resolved when not)
|
||||
- Code moved but same bug exists elsewhere
|
||||
- Variable renamed but logic unchanged
|
||||
- Comments added but no actual fix
|
||||
- Different code path has same issue
|
||||
|
||||
### False Negatives (Marking unresolved when fixed)
|
||||
- Fix uses different approach than expected
|
||||
- Issue fixed via configuration change
|
||||
- Problem resolved by removing feature entirely
|
||||
- Upstream dependency update fixed it
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be thorough**: Check both the specific line AND surrounding context
|
||||
2. **Consider intent**: What was the fix trying to achieve?
|
||||
3. **Look for patterns**: If one instance was fixed, were all instances fixed?
|
||||
4. **Document clearly**: Your evidence should be verifiable by others
|
||||
5. **When uncertain**: Use lower confidence, don't guess at status
|
||||
@@ -0,0 +1,203 @@
|
||||
# Logic and Correctness Review Agent
|
||||
|
||||
You are a focused logic and correctness review agent. You have been spawned by the orchestrating agent to perform deep analysis of algorithmic correctness, edge cases, and state management.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality.
|
||||
|
||||
## Logic Focus Areas
|
||||
|
||||
### 1. Algorithm Correctness
|
||||
- **Wrong Algorithm**: Using inefficient or incorrect algorithm for the problem
|
||||
- **Incorrect Implementation**: Algorithm logic doesn't match the intended behavior
|
||||
- **Missing Steps**: Algorithm is incomplete or skips necessary operations
|
||||
- **Wrong Data Structure**: Using inappropriate data structure for the operation
|
||||
|
||||
### 2. Edge Cases
|
||||
- **Empty Inputs**: Empty arrays, empty strings, null/undefined values
|
||||
- **Boundary Conditions**: First/last elements, zero, negative numbers, max values
|
||||
- **Single Element**: Arrays with one item, strings with one character
|
||||
- **Large Inputs**: Integer overflow, array size limits, string length limits
|
||||
- **Invalid Inputs**: Wrong types, malformed data, unexpected formats
|
||||
|
||||
### 3. Off-By-One Errors
|
||||
- **Loop Bounds**: `<=` vs `<`, starting at 0 vs 1
|
||||
- **Array Access**: Index out of bounds, fence post errors
|
||||
- **String Operations**: Substring boundaries, character positions
|
||||
- **Range Calculations**: Inclusive vs exclusive ranges
|
||||
|
||||
### 4. State Management
|
||||
- **Race Conditions**: Concurrent access to shared state
|
||||
- **Stale State**: Using outdated values after async operations
|
||||
- **State Mutation**: Unintended side effects from mutations
|
||||
- **Initialization**: Using uninitialized or partially initialized state
|
||||
- **Cleanup**: State not reset when it should be
|
||||
|
||||
### 5. Conditional Logic
|
||||
- **Inverted Conditions**: `!condition` when `condition` was intended
|
||||
- **Missing Conditions**: Incomplete if/else chains
|
||||
- **Wrong Operators**: `&&` vs `||`, `==` vs `===`
|
||||
- **Short-Circuit Issues**: Relying on evaluation order incorrectly
|
||||
- **Truthiness Bugs**: `0`, `""`, `[]` being falsy when they're valid values
|
||||
|
||||
### 6. Async/Concurrent Issues
|
||||
- **Missing Await**: Async function called without await
|
||||
- **Promise Handling**: Unhandled rejections, missing error handling
|
||||
- **Deadlocks**: Circular dependencies in async operations
|
||||
- **Race Conditions**: Multiple async operations accessing same resource
|
||||
- **Order Dependencies**: Operations that must run in sequence but don't
|
||||
|
||||
### 7. Type Coercion & Comparisons
|
||||
- **Implicit Coercion**: `"5" + 3 = "53"` vs `"5" - 3 = 2`
|
||||
- **Equality Bugs**: `==` performing unexpected coercion
|
||||
- **Sorting Issues**: Default string sort on numbers `[1, 10, 2]`
|
||||
- **Falsy Confusion**: `0`, `""`, `null`, `undefined`, `NaN`, `false`
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### High Confidence Only
|
||||
- Only report findings with **>80% confidence**
|
||||
- Logic bugs must be demonstrable with a concrete example
|
||||
- If the edge case is theoretical without practical impact, don't report it
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Bug that will cause wrong results or crashes in production
|
||||
- Example: Off-by-one causing data corruption, race condition causing lost updates
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Logic error that will affect some users/cases
|
||||
- Example: Missing null check, incorrect boundary condition
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Edge case not handled that could cause issues
|
||||
- Example: Empty array not handled, large input overflow
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor logic improvement
|
||||
- Example: Unnecessary re-computation, suboptimal algorithm
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Provide Concrete Examples
|
||||
For each finding, provide:
|
||||
1. A concrete input that triggers the bug
|
||||
2. What the current code produces
|
||||
3. What it should produce
|
||||
|
||||
## Code Patterns to Flag
|
||||
|
||||
### Off-By-One Errors
|
||||
```javascript
|
||||
// BUG: Skips last element
|
||||
for (let i = 0; i < arr.length - 1; i++) { }
|
||||
|
||||
// BUG: Accesses beyond array
|
||||
for (let i = 0; i <= arr.length; i++) { }
|
||||
|
||||
// BUG: Wrong substring bounds
|
||||
str.substring(0, str.length - 1) // Missing last char
|
||||
```
|
||||
|
||||
### Edge Case Failures
|
||||
```javascript
|
||||
// BUG: Crashes on empty array
|
||||
const first = arr[0].value; // TypeError if empty
|
||||
|
||||
// BUG: NaN on empty array
|
||||
const avg = sum / arr.length; // Division by zero
|
||||
|
||||
// BUG: Wrong result for single element
|
||||
const max = Math.max(...arr.slice(1)); // Wrong if arr.length === 1
|
||||
```
|
||||
|
||||
### State & Async Bugs
|
||||
```javascript
|
||||
// BUG: Race condition
|
||||
let count = 0;
|
||||
await Promise.all(items.map(async () => {
|
||||
count++; // Not atomic!
|
||||
}));
|
||||
|
||||
// BUG: Stale closure
|
||||
for (var i = 0; i < 5; i++) {
|
||||
setTimeout(() => console.log(i), 100); // All print 5
|
||||
}
|
||||
|
||||
// BUG: Missing await
|
||||
async function process() {
|
||||
getData(); // Returns immediately, doesn't wait
|
||||
useData(); // Data not ready!
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Logic Bugs
|
||||
```javascript
|
||||
// BUG: Inverted condition
|
||||
if (!user.isAdmin) {
|
||||
grantAccess(); // Should be if (user.isAdmin)
|
||||
}
|
||||
|
||||
// BUG: Wrong operator precedence
|
||||
if (a || b && c) { // Evaluates as: a || (b && c)
|
||||
// Probably meant: (a || b) && c
|
||||
}
|
||||
|
||||
// BUG: Falsy check fails for 0
|
||||
if (!value) { // Fails when value is 0
|
||||
value = defaultValue;
|
||||
}
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide findings in JSON format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"file": "src/utils/array.ts",
|
||||
"line": 23,
|
||||
"title": "Off-by-one error in array iteration",
|
||||
"description": "Loop uses `i < arr.length - 1` which skips the last element. For array [1, 2, 3], only processes [1, 2].",
|
||||
"category": "logic",
|
||||
"severity": "high",
|
||||
"example": {
|
||||
"input": "[1, 2, 3]",
|
||||
"actual_output": "Processes [1, 2]",
|
||||
"expected_output": "Processes [1, 2, 3]"
|
||||
},
|
||||
"suggested_fix": "Change loop to `i < arr.length` to include last element",
|
||||
"confidence": 95
|
||||
},
|
||||
{
|
||||
"file": "src/services/counter.ts",
|
||||
"line": 45,
|
||||
"title": "Race condition in concurrent counter increment",
|
||||
"description": "Multiple async operations increment `count` without synchronization. With 10 concurrent increments, final count could be less than 10.",
|
||||
"category": "logic",
|
||||
"severity": "critical",
|
||||
"example": {
|
||||
"input": "10 concurrent increments",
|
||||
"actual_output": "count might be 7, 8, or 9",
|
||||
"expected_output": "count should be 10"
|
||||
},
|
||||
"suggested_fix": "Use atomic operations or a mutex: await mutex.runExclusive(() => count++)",
|
||||
"confidence": 90
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Provide Examples**: Every logic bug should have a concrete triggering input
|
||||
2. **Show Impact**: Explain what goes wrong, not just that something is wrong
|
||||
3. **Be Specific**: Point to exact line and explain the logical flaw
|
||||
4. **Consider Context**: Some "bugs" are intentional (e.g., skipping last element on purpose)
|
||||
5. **Focus on Changed Code**: Prioritize reviewing additions over existing code
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Style issues (naming, formatting)
|
||||
- Security issues (handled by security agent)
|
||||
- Performance issues (unless it's algorithmic complexity bug)
|
||||
- Code quality (duplication, complexity - handled by quality agent)
|
||||
- Test files with intentionally buggy code for testing
|
||||
|
||||
Focus on **logic correctness** - the code doing what it's supposed to do, handling all cases correctly.
|
||||
@@ -183,12 +183,14 @@ if (!exists) {
|
||||
|
||||
**Deduplicate** - Remove duplicates by (file, line, title)
|
||||
|
||||
**Generate Verdict:**
|
||||
**Generate Verdict (Strict Quality Gates):**
|
||||
- **BLOCKED** - If any CRITICAL issues or tests failing
|
||||
- **NEEDS_REVISION** - If HIGH severity issues
|
||||
- **MERGE_WITH_CHANGES** - If only MEDIUM issues
|
||||
- **NEEDS_REVISION** - If HIGH or MEDIUM severity issues (both block merge)
|
||||
- **MERGE_WITH_CHANGES** - If only LOW severity suggestions
|
||||
- **READY_TO_MERGE** - If no blocking issues + tests pass + good coverage
|
||||
|
||||
Note: MEDIUM severity blocks merge because AI fixes quickly - be strict about quality.
|
||||
|
||||
---
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Parallel PR Review Orchestrator
|
||||
|
||||
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialized review agents via the Task tool:
|
||||
|
||||
### security-reviewer
|
||||
**Description**: Security specialist for OWASP Top 10, authentication, injection, cryptographic issues, and sensitive data exposure.
|
||||
**When to use**: PRs touching auth, API endpoints, user input handling, database queries, file operations, or any security-sensitive code.
|
||||
|
||||
### quality-reviewer
|
||||
**Description**: Code quality expert for complexity, duplication, error handling, maintainability, and pattern adherence.
|
||||
**When to use**: PRs with complex logic, large functions, new patterns, or significant business logic changes.
|
||||
|
||||
### logic-reviewer
|
||||
**Description**: Logic and correctness specialist for algorithm verification, edge cases, state management, and race conditions.
|
||||
**When to use**: PRs with algorithmic changes, data transformations, state management, concurrent operations, or bug fixes.
|
||||
|
||||
### codebase-fit-reviewer
|
||||
**Description**: Codebase consistency expert for naming conventions, ecosystem fit, architectural alignment, and avoiding reinvention.
|
||||
**When to use**: PRs introducing new patterns, large additions, or code that might duplicate existing functionality.
|
||||
|
||||
### ai-triage-reviewer
|
||||
**Description**: AI comment validator for triaging comments from CodeRabbit, Gemini Code Assist, Cursor, Greptile, and other AI reviewers.
|
||||
**When to use**: PRs that have existing AI review comments that need validation.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### Phase 1: Analysis
|
||||
|
||||
Analyze the PR thoroughly:
|
||||
|
||||
1. **Understand the Goal**: What does this PR claim to do? Bug fix? Feature? Refactor?
|
||||
2. **Assess Scope**: How many files? What types? What areas of the codebase?
|
||||
3. **Identify Risk Areas**: Security-sensitive? Complex logic? New patterns?
|
||||
4. **Check for AI Comments**: Are there existing AI reviewer comments to triage?
|
||||
|
||||
### Phase 2: Delegation
|
||||
|
||||
Based on your analysis, invoke the appropriate specialist agents. You can invoke multiple agents in parallel by calling the Task tool multiple times in the same response.
|
||||
|
||||
**Delegation Guidelines** (YOU decide, these are suggestions):
|
||||
|
||||
- **Small PRs (1-5 files)**: At minimum, invoke one agent for deep analysis. Choose based on content.
|
||||
- **Medium PRs (5-20 files)**: Invoke 2-3 agents covering different aspects (e.g., security + quality).
|
||||
- **Large PRs (20+ files)**: Invoke 3-4 agents with focused file assignments.
|
||||
- **Security-sensitive changes**: Always invoke security-reviewer.
|
||||
- **Complex logic changes**: Always invoke logic-reviewer.
|
||||
- **New patterns/large additions**: Always invoke codebase-fit-reviewer.
|
||||
- **Existing AI comments**: Always invoke ai-triage-reviewer.
|
||||
|
||||
**Example delegation**:
|
||||
```
|
||||
For a PR adding a new authentication endpoint:
|
||||
- Invoke security-reviewer for auth logic
|
||||
- Invoke quality-reviewer for code structure
|
||||
- Invoke logic-reviewer for edge cases in auth flow
|
||||
```
|
||||
|
||||
### Phase 3: Synthesis
|
||||
|
||||
After receiving agent results, synthesize findings:
|
||||
|
||||
1. **Aggregate**: Collect all findings from all agents
|
||||
2. **Cross-validate**:
|
||||
- If multiple agents report the same issue → boost confidence
|
||||
- If agents conflict → use your judgment to resolve
|
||||
3. **Deduplicate**: Remove overlapping findings (same file + line + issue type)
|
||||
4. **Filter**: Only include findings with confidence ≥80%
|
||||
5. **Generate Verdict**: Based on severity of remaining findings
|
||||
|
||||
## Output Format
|
||||
|
||||
After synthesis, output your final review in this JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "Brief description of what you analyzed and why you chose those agents",
|
||||
"agents_invoked": ["security-reviewer", "quality-reviewer"],
|
||||
"findings": [
|
||||
{
|
||||
"id": "finding-1",
|
||||
"file": "src/auth/login.ts",
|
||||
"line": 45,
|
||||
"end_line": 52,
|
||||
"title": "SQL injection vulnerability in user lookup",
|
||||
"description": "User input directly interpolated into SQL query",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"confidence": 0.95,
|
||||
"suggested_fix": "Use parameterized queries",
|
||||
"fixable": true,
|
||||
"source_agents": ["security-reviewer"],
|
||||
"cross_validated": false
|
||||
}
|
||||
],
|
||||
"agent_agreement": {
|
||||
"agreed_findings": ["finding-1", "finding-3"],
|
||||
"conflicting_findings": [],
|
||||
"resolution_notes": ""
|
||||
},
|
||||
"verdict": "NEEDS_REVISION",
|
||||
"verdict_reasoning": "Critical SQL injection vulnerability must be fixed before merge"
|
||||
}
|
||||
```
|
||||
|
||||
## Verdict Types (Strict Quality Gates)
|
||||
|
||||
We use strict quality gates because AI can fix issues quickly. Only LOW severity findings are optional.
|
||||
|
||||
- **READY_TO_MERGE**: No blocking issues found - can merge
|
||||
- **MERGE_WITH_CHANGES**: Only LOW (Suggestion) severity findings - can merge but consider addressing
|
||||
- **NEEDS_REVISION**: HIGH or MEDIUM severity findings that must be fixed before merge
|
||||
- **BLOCKED**: CRITICAL severity issues or failing tests - must be fixed before merge
|
||||
|
||||
**Severity → Verdict Mapping:**
|
||||
- CRITICAL → BLOCKED (must fix)
|
||||
- HIGH → NEEDS_REVISION (required fix)
|
||||
- MEDIUM → NEEDS_REVISION (recommended, improves quality - also blocks merge)
|
||||
- LOW → MERGE_WITH_CHANGES (optional suggestions)
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **YOU Decide**: No hardcoded rules - you analyze and choose agents based on content
|
||||
2. **Parallel Execution**: Invoke multiple agents in the same turn for speed
|
||||
3. **Thoroughness**: Every PR deserves analysis - never skip because it "looks simple"
|
||||
4. **Cross-Validation**: Multiple agents agreeing increases confidence
|
||||
5. **High Confidence**: Only report findings with ≥80% confidence
|
||||
6. **Actionable**: Every finding must have a specific, actionable fix
|
||||
7. **Project Agnostic**: Works for any project type - backend, frontend, fullstack, any language
|
||||
|
||||
## Remember
|
||||
|
||||
You are the orchestrator. The specialist agents provide deep expertise, but YOU make the final decisions about:
|
||||
- Which agents to invoke
|
||||
- How to resolve conflicts
|
||||
- What findings to include
|
||||
- What verdict to give
|
||||
|
||||
Quality over speed. A missed bug in production is far worse than spending extra time on review.
|
||||
@@ -62,15 +62,19 @@ Perform a thorough code quality review of the provided code changes. Focus on ma
|
||||
- If it's subjective or debatable, don't report it
|
||||
- Focus on objective quality issues
|
||||
|
||||
### Severity Classification
|
||||
- **CRITICAL**: Bug that will cause failures in production
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Bug that will cause failures in production
|
||||
- Example: Unhandled promise rejection, memory leak
|
||||
- **HIGH**: Significant quality issue affecting maintainability
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Significant quality issue affecting maintainability
|
||||
- Example: 200-line function, duplicated business logic across 5 files
|
||||
- **MEDIUM**: Quality concern worth addressing
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Quality concern that improves code quality
|
||||
- Example: Missing error handling, magic numbers
|
||||
- **LOW**: Minor improvement suggestion
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor improvement suggestion
|
||||
- Example: Variable naming, minor refactoring opportunity
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Contextual Analysis
|
||||
- Consider project conventions (don't enforce personal preferences)
|
||||
|
||||
@@ -264,11 +264,11 @@ Return a JSON array with this structure:
|
||||
### Required Fields
|
||||
|
||||
- **id**: Unique identifier (e.g., "finding-1", "finding-2")
|
||||
- **severity**: `critical` | `high` | `medium` | `low`
|
||||
- **critical**: Must fix before merge (security vulnerabilities, data loss risks)
|
||||
- **high**: Should fix before merge (significant bugs, major quality issues)
|
||||
- **medium**: Recommended to fix (code quality, maintainability concerns)
|
||||
- **low**: Suggestions for improvement (minor enhancements)
|
||||
- **severity**: `critical` | `high` | `medium` | `low` (Strict Quality Gates - all block merge except LOW)
|
||||
- **critical** (Blocker): Must fix before merge (security vulnerabilities, data loss risks) - **Blocks merge: YES**
|
||||
- **high** (Required): Should fix before merge (significant bugs, major quality issues) - **Blocks merge: YES**
|
||||
- **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly)
|
||||
- **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO**
|
||||
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
|
||||
- **confidence**: Float 0.0-1.0 representing your confidence this is a genuine issue (must be ≥0.80)
|
||||
- **title**: Short, specific summary (max 80 chars)
|
||||
|
||||
@@ -57,15 +57,19 @@ Perform a thorough security review of the provided code changes, focusing ONLY o
|
||||
- If you're unsure, don't report it
|
||||
- Prefer false negatives over false positives
|
||||
|
||||
### Severity Classification
|
||||
- **CRITICAL**: Exploitable vulnerability leading to data breach, RCE, or system compromise
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise
|
||||
- Example: SQL injection, hardcoded admin password
|
||||
- **HIGH**: Serious security flaw that could be exploited
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Serious security flaw that could be exploited
|
||||
- Example: Missing authentication check, XSS vulnerability
|
||||
- **MEDIUM**: Security weakness that increases risk
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Security weakness that increases risk
|
||||
- Example: Weak password requirements, missing security headers
|
||||
- **LOW**: Best practice violation, minimal risk
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about security)
|
||||
- **LOW** (Suggestion): Best practice violation, minimal risk
|
||||
- Example: Using MD5 for non-security checksums
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Contextual Analysis
|
||||
- Consider the application type (public API vs internal tool)
|
||||
|
||||
@@ -34,6 +34,11 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .file_lock import FileLock, atomic_write
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from file_lock import FileLock, atomic_write
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotDetectionState:
|
||||
@@ -61,12 +66,14 @@ class BotDetectionState:
|
||||
)
|
||||
|
||||
def save(self, state_dir: Path) -> None:
|
||||
"""Save state to disk."""
|
||||
"""Save state to disk with file locking for concurrent safety."""
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_file = state_dir / "bot_detection_state.json"
|
||||
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
# Use file locking to prevent concurrent write corruption
|
||||
with FileLock(state_file, timeout=5.0, exclusive=True):
|
||||
with atomic_write(state_file) as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, state_dir: Path) -> BotDetectionState:
|
||||
@@ -311,8 +318,9 @@ class BotDetector:
|
||||
return True, reason
|
||||
|
||||
# Check 2: Is the latest commit by the bot?
|
||||
# Note: GitHub API returns commits oldest-first, so commits[-1] is the latest
|
||||
if commits and not self.review_own_prs:
|
||||
latest_commit = commits[0] if commits else None
|
||||
latest_commit = commits[-1] if commits else None
|
||||
if latest_commit and self.is_bot_commit(latest_commit):
|
||||
reason = "Latest commit authored by bot (likely an auto-fix)"
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
@@ -403,3 +411,44 @@ class BotDetector:
|
||||
"total_reviews_performed": total_reviews,
|
||||
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
|
||||
}
|
||||
|
||||
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
|
||||
"""
|
||||
Remove tracking state for PRs that haven't been reviewed recently.
|
||||
|
||||
This prevents unbounded growth of the state file by cleaning up
|
||||
entries for PRs that are likely closed/merged.
|
||||
|
||||
Args:
|
||||
max_age_days: Remove PRs not reviewed in this many days (default: 30)
|
||||
|
||||
Returns:
|
||||
Number of PRs cleaned up
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=max_age_days)
|
||||
prs_to_remove: list[str] = []
|
||||
|
||||
for pr_key, last_review_str in self.state.last_review_times.items():
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
if last_review < cutoff:
|
||||
prs_to_remove.append(pr_key)
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp - mark for removal
|
||||
prs_to_remove.append(pr_key)
|
||||
|
||||
# Remove stale PRs
|
||||
for pr_key in prs_to_remove:
|
||||
if pr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[pr_key]
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
|
||||
if prs_to_remove:
|
||||
self.state.save(self.state_dir)
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
|
||||
return len(prs_to_remove)
|
||||
|
||||
@@ -121,6 +121,11 @@ AI_BOT_PATTERNS: dict[str, str] = {
|
||||
"codium-ai[bot]": "Qodo",
|
||||
"codiumai-agent": "Qodo",
|
||||
"qodo-merge-bot": "Qodo",
|
||||
# === Google AI ===
|
||||
"gemini-code-assist": "Gemini Code Assist",
|
||||
"gemini-code-assist[bot]": "Gemini Code Assist",
|
||||
"google-code-assist": "Gemini Code Assist",
|
||||
"google-code-assist[bot]": "Gemini Code Assist",
|
||||
# === AI Coding Assistants ===
|
||||
"copilot": "GitHub Copilot",
|
||||
"copilot[bot]": "GitHub Copilot",
|
||||
@@ -352,7 +357,7 @@ class PRContextGatherer:
|
||||
|
||||
if proc.returncode == 0:
|
||||
print(
|
||||
f"[Context] Fetched PR refs: {head_sha[:8]}...{base_sha[:8]}",
|
||||
f"[Context] Fetched PR refs: base={base_sha[:8]} → head={head_sha[:8]}",
|
||||
flush=True,
|
||||
)
|
||||
return True
|
||||
@@ -870,8 +875,9 @@ class PRContextGatherer:
|
||||
# Start from the directory containing the source file
|
||||
base_dir = source_path.parent
|
||||
|
||||
# Resolve relative path
|
||||
resolved = (base_dir / import_path).resolve()
|
||||
# Resolve relative path - MUST prepend project_dir to resolve correctly
|
||||
# when CWD is different from project root (e.g., running from apps/backend/)
|
||||
resolved = (self.project_dir / base_dir / import_path).resolve()
|
||||
|
||||
# Try common extensions if no extension provided
|
||||
if not resolved.suffix:
|
||||
@@ -1035,6 +1041,7 @@ class FollowupContextGatherer:
|
||||
previous_review=self.previous_review,
|
||||
previous_commit_sha=previous_sha,
|
||||
current_commit_sha=current_sha,
|
||||
error=f"Failed to compare commits: {e}",
|
||||
)
|
||||
|
||||
# Extract data from comparison
|
||||
|
||||
@@ -375,6 +375,13 @@ class PRReviewResult:
|
||||
default_factory=list
|
||||
) # New issues in recent commits
|
||||
|
||||
# Posted findings tracking (for frontend state sync)
|
||||
has_posted_findings: bool = False # True if any findings have been posted to GitHub
|
||||
posted_finding_ids: list[str] = field(
|
||||
default_factory=list
|
||||
) # IDs of posted findings
|
||||
posted_at: str | None = None # Timestamp when findings were posted
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pr_number": self.pr_number,
|
||||
@@ -401,6 +408,10 @@ class PRReviewResult:
|
||||
"resolved_findings": self.resolved_findings,
|
||||
"unresolved_findings": self.unresolved_findings,
|
||||
"new_findings_since_last_review": self.new_findings_since_last_review,
|
||||
# Posted findings tracking
|
||||
"has_posted_findings": self.has_posted_findings,
|
||||
"posted_finding_ids": self.posted_finding_ids,
|
||||
"posted_at": self.posted_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -443,6 +454,10 @@ class PRReviewResult:
|
||||
new_findings_since_last_review=data.get(
|
||||
"new_findings_since_last_review", []
|
||||
),
|
||||
# Posted findings tracking
|
||||
has_posted_findings=data.get("has_posted_findings", False),
|
||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
||||
posted_at=data.get("posted_at"),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
@@ -529,6 +544,9 @@ class FollowupReviewContext:
|
||||
# These are different from comments - they're full review submissions with body text
|
||||
pr_reviews_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
# Error flag - if set, context gathering failed and data may be incomplete
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageResult:
|
||||
@@ -773,7 +791,12 @@ class GitHubRunnerConfig:
|
||||
auto_post_reviews: bool = False
|
||||
allow_fix_commits: bool = True
|
||||
review_own_prs: bool = False # Whether bot can review its own PRs
|
||||
use_orchestrator_review: bool = True # Use new Opus 4.5 orchestrating agent
|
||||
use_orchestrator_review: bool = (
|
||||
True # DEPRECATED: No longer used, kept for config compatibility
|
||||
)
|
||||
use_parallel_orchestrator: bool = (
|
||||
True # Use SDK subagent parallel orchestrator (default)
|
||||
)
|
||||
|
||||
# Model settings
|
||||
model: str = "claude-sonnet-4-20250514"
|
||||
|
||||
@@ -277,12 +277,16 @@ class GitHubOrchestrator:
|
||||
# PR REVIEW WORKFLOW
|
||||
# =========================================================================
|
||||
|
||||
async def review_pr(self, pr_number: int) -> PRReviewResult:
|
||||
async def review_pr(
|
||||
self, pr_number: int, force_review: bool = False
|
||||
) -> PRReviewResult:
|
||||
"""
|
||||
Perform AI-powered review of a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number to review
|
||||
force_review: If True, bypass the "already reviewed" check and force a new review.
|
||||
Useful for re-validating a PR or testing the review system.
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and overall assessment
|
||||
@@ -321,11 +325,34 @@ class GitHubOrchestrator:
|
||||
commits=pr_context.commits,
|
||||
)
|
||||
|
||||
# Allow forcing a review to bypass "already reviewed" check
|
||||
if should_skip and force_review and "Already reviewed" in skip_reason:
|
||||
print(
|
||||
f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}",
|
||||
flush=True,
|
||||
)
|
||||
should_skip = False
|
||||
|
||||
if should_skip:
|
||||
print(
|
||||
f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# If skipping because "Already reviewed", return the existing review
|
||||
# instead of creating a new empty "skipped" result
|
||||
if "Already reviewed" in skip_reason:
|
||||
existing_review = PRReviewResult.load(self.github_dir, pr_number)
|
||||
if existing_review:
|
||||
print(
|
||||
"[BOT DETECTION] Returning existing review (no new commits)",
|
||||
flush=True,
|
||||
)
|
||||
# Don't overwrite - return the existing review as-is
|
||||
# The frontend will see "no new commits" via the newCommitsCheck
|
||||
return existing_review
|
||||
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
@@ -535,6 +562,30 @@ class GitHubOrchestrator:
|
||||
)
|
||||
followup_context = await gatherer.gather()
|
||||
|
||||
# Check if context gathering failed
|
||||
if followup_context.error:
|
||||
print(
|
||||
f"[Followup] Context gathering failed: {followup_context.error}",
|
||||
flush=True,
|
||||
)
|
||||
# Return an error result instead of silently returning incomplete data
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up review failed: {followup_context.error}",
|
||||
overall_status="comment",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning=f"Context gathering failed: {followup_context.error}",
|
||||
error=followup_context.error,
|
||||
reviewed_commit_sha=followup_context.current_commit_sha
|
||||
or previous_review.reviewed_commit_sha,
|
||||
is_followup_review=True,
|
||||
)
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Check if there are new commits
|
||||
if not followup_context.commits_since_review:
|
||||
print(
|
||||
@@ -566,20 +617,49 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Run follow-up review
|
||||
reviewer = FollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.get("phase", "analyzing"),
|
||||
p.get("progress", 50),
|
||||
p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
# Use parallel orchestrator for follow-up if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
from .services.parallel_followup_reviewer import (
|
||||
ParallelFollowupReviewer,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from services.parallel_followup_reviewer import (
|
||||
ParallelFollowupReviewer,
|
||||
)
|
||||
|
||||
result = await reviewer.review_followup(followup_context)
|
||||
reviewer = ParallelFollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.phase if hasattr(p, "phase") else p.get("phase", "analyzing"),
|
||||
p.progress if hasattr(p, "progress") else p.get("progress", 50),
|
||||
p.message
|
||||
if hasattr(p, "message")
|
||||
else p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
result = await reviewer.review(followup_context)
|
||||
else:
|
||||
# Fall back to sequential follow-up reviewer
|
||||
reviewer = FollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.get("phase", "analyzing"),
|
||||
p.get("progress", 50),
|
||||
p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
result = await reviewer.review_followup(followup_context)
|
||||
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
@@ -621,6 +701,8 @@ class GitHubOrchestrator:
|
||||
# Count by severity
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM]
|
||||
low = [f for f in findings if f.severity == ReviewSeverity.LOW]
|
||||
|
||||
# NEW: Verification failures are ALWAYS blockers (even if not critical severity)
|
||||
verification_failures = [
|
||||
@@ -709,9 +791,17 @@ class GitHubOrchestrator:
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = f"{len(blockers)} issues must be addressed"
|
||||
elif high:
|
||||
elif high or medium:
|
||||
# High and Medium severity findings block merge
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
total = len(high) + len(medium)
|
||||
reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)"
|
||||
if low:
|
||||
reasoning += f", {len(low)} suggestions"
|
||||
elif low:
|
||||
# Only Low severity suggestions - can merge but consider addressing
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = f"{len(high)} high-priority issues to address"
|
||||
reasoning = f"{len(low)} suggestion(s) to consider"
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
reasoning = "No blocking issues found"
|
||||
|
||||
@@ -208,7 +208,9 @@ async def cmd_review_pr(args) -> int:
|
||||
f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True
|
||||
)
|
||||
|
||||
result = await orchestrator.review_pr(args.pr_number)
|
||||
# Pass force_review flag if --force was specified
|
||||
force_review = getattr(args, "force", False)
|
||||
result = await orchestrator.review_pr(args.pr_number, force_review=force_review)
|
||||
|
||||
if debug:
|
||||
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
|
||||
@@ -681,6 +683,11 @@ def main():
|
||||
action="store_true",
|
||||
help="Automatically post review to GitHub",
|
||||
)
|
||||
review_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force a new review even if commit was already reviewed",
|
||||
)
|
||||
|
||||
# followup-review-pr command
|
||||
followup_parser = subparsers.add_parser(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Category Mapping Utilities
|
||||
===========================
|
||||
|
||||
Shared utilities for mapping AI-generated category names to valid ReviewCategory enum values.
|
||||
|
||||
This module provides a centralized category mapping system used across all PR reviewers
|
||||
(orchestrator, follow-up, parallel) to ensure consistent category normalization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from ..models import ReviewCategory
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import ReviewCategory
|
||||
|
||||
|
||||
# Map AI-generated category names to valid ReviewCategory enum values
|
||||
CATEGORY_MAPPING: dict[str, ReviewCategory] = {
|
||||
# Direct matches (already valid ReviewCategory values)
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
# AI-generated alternatives that need mapping
|
||||
"logic": ReviewCategory.QUALITY, # Logic errors → quality
|
||||
"codebase_fit": ReviewCategory.PATTERN, # Codebase fit → pattern adherence
|
||||
"correctness": ReviewCategory.QUALITY, # Code correctness → quality
|
||||
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
|
||||
"testing": ReviewCategory.TEST, # Testing → test
|
||||
"documentation": ReviewCategory.DOCS, # Documentation → docs
|
||||
"bug": ReviewCategory.QUALITY, # Bug → quality
|
||||
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
|
||||
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
|
||||
"readability": ReviewCategory.STYLE, # Readability → style
|
||||
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern (hyphen normalized to underscore)
|
||||
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
|
||||
"complexity": ReviewCategory.QUALITY, # Complexity → quality
|
||||
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
|
||||
"unused": ReviewCategory.REDUNDANCY, # Unused code → redundancy
|
||||
# Follow-up specific mappings
|
||||
"regression": ReviewCategory.QUALITY, # Regression → quality
|
||||
"incomplete_fix": ReviewCategory.QUALITY, # Incomplete fix → quality
|
||||
}
|
||||
|
||||
|
||||
def map_category(raw_category: str) -> ReviewCategory:
|
||||
"""
|
||||
Map an AI-generated category string to a valid ReviewCategory enum.
|
||||
|
||||
Args:
|
||||
raw_category: Raw category string from AI (e.g., "best-practices", "logic", "security")
|
||||
|
||||
Returns:
|
||||
ReviewCategory: Normalized category enum value. Defaults to QUALITY if unknown.
|
||||
|
||||
Examples:
|
||||
>>> map_category("security")
|
||||
ReviewCategory.SECURITY
|
||||
>>> map_category("best-practices")
|
||||
ReviewCategory.PATTERN
|
||||
>>> map_category("unknown-category")
|
||||
ReviewCategory.QUALITY
|
||||
"""
|
||||
# Normalize: lowercase, strip whitespace, replace hyphens with underscores
|
||||
normalized = raw_category.lower().strip().replace("-", "_")
|
||||
|
||||
# Look up in mapping, default to QUALITY for unknown categories
|
||||
return CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
|
||||
@@ -33,6 +33,7 @@ try:
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .prompt_manager import PromptManager
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
except (ImportError, ValueError, SystemError):
|
||||
@@ -43,41 +44,12 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from services.category_utils import map_category
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.pydantic_models import FollowupReviewResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Category mapping for AI responses
|
||||
_CATEGORY_MAPPING = {
|
||||
# Direct matches (already valid)
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
# AI-generated alternatives that need mapping
|
||||
"correctness": ReviewCategory.QUALITY, # Logic/code correctness → quality
|
||||
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
|
||||
"testing": ReviewCategory.TEST, # Testing → test
|
||||
"documentation": ReviewCategory.DOCS, # Documentation → docs
|
||||
"bug": ReviewCategory.QUALITY, # Bug → quality
|
||||
"logic": ReviewCategory.QUALITY, # Logic error → quality
|
||||
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
|
||||
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
|
||||
"readability": ReviewCategory.STYLE, # Readability → style
|
||||
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern
|
||||
"best-practices": ReviewCategory.PATTERN, # With hyphen
|
||||
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
|
||||
"complexity": ReviewCategory.QUALITY, # Complexity → quality
|
||||
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
|
||||
"unused": ReviewCategory.REDUNDANCY, # Unused → redundancy
|
||||
}
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
@@ -305,9 +277,9 @@ class FollowupReviewer:
|
||||
resolved.append(finding)
|
||||
else:
|
||||
# File was modified but the specific line wasn't clearly changed
|
||||
# Consider it potentially resolved (benefit of the doubt)
|
||||
# Could be more sophisticated with AST analysis
|
||||
resolved.append(finding)
|
||||
# Mark as unresolved - the contributor needs to address the actual issue
|
||||
# "Benefit of the doubt" was wrong - if the line wasn't changed, the issue persists
|
||||
unresolved.append(finding)
|
||||
|
||||
return resolved, unresolved
|
||||
|
||||
@@ -470,11 +442,20 @@ class FollowupReviewer:
|
||||
high_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.HIGH
|
||||
)
|
||||
medium_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.MEDIUM
|
||||
)
|
||||
low_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.LOW
|
||||
)
|
||||
critical_new = sum(
|
||||
1 for f in new_findings if f.severity == ReviewSeverity.CRITICAL
|
||||
)
|
||||
high_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.HIGH)
|
||||
medium_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.MEDIUM)
|
||||
low_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.LOW)
|
||||
|
||||
# Critical and High are always blockers
|
||||
for f in unresolved_findings:
|
||||
if f.severity in [ReviewSeverity.CRITICAL, ReviewSeverity.HIGH]:
|
||||
blockers.append(f"Unresolved: {f.title} ({f.file}:{f.line})")
|
||||
@@ -490,17 +471,25 @@ class FollowupReviewer:
|
||||
f"Still blocked by {critical_unresolved + critical_new} critical issues "
|
||||
f"({critical_unresolved} unresolved, {critical_new} new)"
|
||||
)
|
||||
elif high_unresolved > 0 or high_new > 0:
|
||||
elif (
|
||||
high_unresolved > 0
|
||||
or high_new > 0
|
||||
or medium_unresolved > 0
|
||||
or medium_new > 0
|
||||
):
|
||||
# High and Medium severity findings block merge
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
total_blocking = high_unresolved + high_new + medium_unresolved + medium_new
|
||||
reasoning = (
|
||||
f"{high_unresolved + high_new} high-priority issues "
|
||||
f"({high_unresolved} unresolved, {high_new} new)"
|
||||
f"{total_blocking} issue(s) must be addressed "
|
||||
f"({high_unresolved + medium_unresolved} unresolved, {high_new + medium_new} new)"
|
||||
)
|
||||
elif len(unresolved_findings) > 0 or len(new_findings) > 0:
|
||||
elif low_unresolved > 0 or low_new > 0:
|
||||
# Only Low severity suggestions remaining - can merge but consider addressing
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = (
|
||||
f"{resolved_count} issues resolved. "
|
||||
f"{len(unresolved_findings)} remaining, {len(new_findings)} new minor issues."
|
||||
f"{low_unresolved + low_new} suggestion(s) to consider."
|
||||
)
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
@@ -650,12 +639,12 @@ class FollowupReviewer:
|
||||
### AI BOT COMMENTS SINCE LAST REVIEW:
|
||||
{ai_comments_text if ai_comments_text else "No AI bot comments."}
|
||||
|
||||
### PR REVIEWS SINCE LAST REVIEW (Cursor, CodeRabbit, etc.):
|
||||
### PR REVIEWS SINCE LAST REVIEW (CodeRabbit, Gemini Code Assist, Cursor, etc.):
|
||||
{pr_reviews_text if pr_reviews_text else "No PR reviews since last review."}
|
||||
|
||||
---
|
||||
|
||||
**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like Cursor, CodeRabbit, Greptile, etc. that may have identified issues in the recent changes. You should:
|
||||
**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc. that may have identified issues in the recent changes. You should:
|
||||
1. Consider their findings when evaluating the code
|
||||
2. Create new findings for valid issues they identified that haven't been addressed
|
||||
3. Note if the recent commits addressed concerns raised in these reviews
|
||||
@@ -777,7 +766,7 @@ Analyze this follow-up review context and provide your structured response.
|
||||
PRReviewFinding(
|
||||
id=f.id,
|
||||
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.MEDIUM),
|
||||
category=_CATEGORY_MAPPING.get(f.category, ReviewCategory.QUALITY),
|
||||
category=map_category(f.category),
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
file=f.file,
|
||||
@@ -794,7 +783,7 @@ Analyze this follow-up review context and provide your structured response.
|
||||
PRReviewFinding(
|
||||
id=f.id,
|
||||
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.LOW),
|
||||
category=_CATEGORY_MAPPING.get(f.category, ReviewCategory.QUALITY),
|
||||
category=map_category(f.category),
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
file=f.file,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,752 @@
|
||||
"""
|
||||
Parallel Follow-up PR Reviewer
|
||||
===============================
|
||||
|
||||
PR follow-up reviewer using Claude Agent SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator analyzes incremental changes and delegates to specialized agents:
|
||||
- resolution-verifier: Verifies previous findings are addressed
|
||||
- new-code-reviewer: Reviews new code for issues
|
||||
- comment-analyzer: Processes contributor and AI feedback
|
||||
|
||||
Key Design:
|
||||
- AI decides which agents to invoke (NOT programmatic rules)
|
||||
- Subagents defined via SDK `agents={}` parameter
|
||||
- SDK handles parallel execution automatically
|
||||
- User-configured model from frontend settings (no hardcoding)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models import FollowupReviewContext
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.category_utils import map_category
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
"high": ReviewSeverity.HIGH,
|
||||
"medium": ReviewSeverity.MEDIUM,
|
||||
"low": ReviewSeverity.LOW,
|
||||
}
|
||||
|
||||
|
||||
def _map_severity(severity_str: str) -> ReviewSeverity:
|
||||
"""Map severity string to ReviewSeverity enum."""
|
||||
return _SEVERITY_MAPPING.get(severity_str.lower(), ReviewSeverity.MEDIUM)
|
||||
|
||||
|
||||
class ParallelFollowupReviewer:
|
||||
"""
|
||||
Follow-up PR reviewer using SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator:
|
||||
1. Analyzes incremental changes since last review
|
||||
2. Delegates to appropriate specialist agents (SDK handles parallel execution)
|
||||
3. Synthesizes findings into a final merge verdict
|
||||
|
||||
Specialist Agents:
|
||||
- resolution-verifier: Verifies previous findings are addressed
|
||||
- new-code-reviewer: Reviews new code for issues
|
||||
- comment-analyzer: Processes contributor and AI feedback
|
||||
|
||||
Model Configuration:
|
||||
- Orchestrator uses user-configured model from frontend settings
|
||||
- Specialist agents use model="inherit" (same as orchestrator)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
config: GitHubRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""Load a prompt file from the prompts/github directory."""
|
||||
prompt_file = (
|
||||
Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename
|
||||
)
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
Define specialist agents for follow-up review.
|
||||
|
||||
Each agent has:
|
||||
- description: When the orchestrator should invoke this agent
|
||||
- prompt: System prompt for the agent
|
||||
- tools: Tools the agent can use (read-only for PR review)
|
||||
- model: "inherit" = use same model as orchestrator (user's choice)
|
||||
"""
|
||||
# Load agent prompts from files
|
||||
resolution_prompt = self._load_prompt("pr_followup_resolution_agent.md")
|
||||
newcode_prompt = self._load_prompt("pr_followup_newcode_agent.md")
|
||||
comment_prompt = self._load_prompt("pr_followup_comment_agent.md")
|
||||
|
||||
return {
|
||||
"resolution-verifier": AgentDefinition(
|
||||
description=(
|
||||
"Resolution verification specialist. Use to verify whether previous "
|
||||
"findings have been addressed. Analyzes diffs to determine if issues "
|
||||
"are truly fixed, partially fixed, or still unresolved. "
|
||||
"Invoke when: There are previous findings to verify."
|
||||
),
|
||||
prompt=resolution_prompt
|
||||
or "You verify whether previous findings are resolved.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"new-code-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"New code analysis specialist. Reviews code added since last review "
|
||||
"for security, logic, quality issues, and regressions. "
|
||||
"Invoke when: There are substantial code changes (>50 lines diff) or "
|
||||
"changes to security-sensitive areas."
|
||||
),
|
||||
prompt=newcode_prompt or "You review new code for issues.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"comment-analyzer": AgentDefinition(
|
||||
description=(
|
||||
"Comment and feedback analyst. Processes contributor comments and "
|
||||
"AI tool reviews (CodeRabbit, Cursor, Gemini, etc.) to identify "
|
||||
"unanswered questions and valid concerns. "
|
||||
"Invoke when: There are comments or formal reviews since last review."
|
||||
),
|
||||
prompt=comment_prompt or "You analyze comments and feedback.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
}
|
||||
|
||||
def _format_previous_findings(self, context: FollowupReviewContext) -> str:
|
||||
"""Format previous findings for the prompt."""
|
||||
previous_findings = context.previous_review.findings
|
||||
if not previous_findings:
|
||||
return "No previous findings to verify."
|
||||
|
||||
lines = []
|
||||
for f in previous_findings:
|
||||
lines.append(
|
||||
f"- **{f.id}** [{f.severity.value}] {f.title}\n"
|
||||
f" File: {f.file}:{f.line}\n"
|
||||
f" {f.description[:200]}..."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_commits(self, context: FollowupReviewContext) -> str:
|
||||
"""Format new commits for the prompt."""
|
||||
if not context.commits_since_review:
|
||||
return "No new commits."
|
||||
|
||||
lines = []
|
||||
for commit in context.commits_since_review[:20]: # Limit to 20 commits
|
||||
sha = commit.get("sha", "")[:7]
|
||||
message = commit.get("commit", {}).get("message", "").split("\n")[0]
|
||||
author = commit.get("commit", {}).get("author", {}).get("name", "unknown")
|
||||
lines.append(f"- `{sha}` by {author}: {message}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_comments(self, context: FollowupReviewContext) -> str:
|
||||
"""Format contributor comments for the prompt."""
|
||||
if not context.contributor_comments_since_review:
|
||||
return "No contributor comments since last review."
|
||||
|
||||
lines = []
|
||||
for comment in context.contributor_comments_since_review[:15]:
|
||||
author = comment.get("user", {}).get("login", "unknown")
|
||||
body = comment.get("body", "")[:300]
|
||||
lines.append(f"**@{author}**: {body}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
def _format_ai_reviews(self, context: FollowupReviewContext) -> str:
|
||||
"""Format AI bot reviews and comments for the prompt."""
|
||||
ai_content = []
|
||||
|
||||
# AI bot comments
|
||||
for comment in context.ai_bot_comments_since_review[:10]:
|
||||
author = comment.get("user", {}).get("login", "unknown")
|
||||
body = comment.get("body", "")[:500]
|
||||
ai_content.append(f"**{author}** (comment):\n{body}")
|
||||
|
||||
# Formal PR reviews from AI tools
|
||||
for review in context.pr_reviews_since_review[:5]:
|
||||
author = review.get("user", {}).get("login", "unknown")
|
||||
body = review.get("body", "")[:1000]
|
||||
state = review.get("state", "unknown")
|
||||
ai_content.append(f"**{author}** ({state}):\n{body}")
|
||||
|
||||
if not ai_content:
|
||||
return "No AI tool feedback since last review."
|
||||
|
||||
return "\n\n---\n\n".join(ai_content)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
|
||||
"""Build full prompt for orchestrator with follow-up context."""
|
||||
# Load orchestrator prompt
|
||||
base_prompt = self._load_prompt("pr_followup_orchestrator.md")
|
||||
if not base_prompt:
|
||||
base_prompt = "You are a follow-up PR reviewer. Verify resolutions and find new issues."
|
||||
|
||||
# Build context sections
|
||||
previous_findings = self._format_previous_findings(context)
|
||||
commits = self._format_commits(context)
|
||||
contributor_comments = self._format_comments(context)
|
||||
ai_reviews = self._format_ai_reviews(context)
|
||||
|
||||
# Truncate diff if too long
|
||||
MAX_DIFF_CHARS = 100_000
|
||||
diff_content = context.diff_since_review
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
followup_context = f"""
|
||||
---
|
||||
|
||||
## Follow-up Review Context
|
||||
|
||||
**PR Number:** {context.pr_number}
|
||||
**Previous Review Commit:** {context.previous_commit_sha[:8]}
|
||||
**Current HEAD:** {context.current_commit_sha[:8]}
|
||||
**New Commits:** {len(context.commits_since_review)}
|
||||
**Files Changed:** {len(context.files_changed_since_review)}
|
||||
|
||||
### Previous Review Summary
|
||||
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
|
||||
|
||||
### Previous Findings to Verify
|
||||
{previous_findings}
|
||||
|
||||
### New Commits Since Last Review
|
||||
{commits}
|
||||
|
||||
### Files Changed Since Last Review
|
||||
{chr(10).join(f"- {f}" for f in context.files_changed_since_review[:30])}
|
||||
|
||||
### Contributor Comments Since Last Review
|
||||
{contributor_comments}
|
||||
|
||||
### AI Tool Feedback Since Last Review
|
||||
{ai_reviews}
|
||||
|
||||
### Diff Since Last Review
|
||||
```diff
|
||||
{diff_content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Now analyze this follow-up and delegate to the appropriate specialist agents.
|
||||
Remember: YOU decide which agents to invoke based on YOUR analysis.
|
||||
The SDK will run invoked agents in parallel automatically.
|
||||
"""
|
||||
|
||||
return base_prompt + followup_context
|
||||
|
||||
async def review(self, context: FollowupReviewContext) -> PRReviewResult:
|
||||
"""
|
||||
Main follow-up review entry point.
|
||||
|
||||
Args:
|
||||
context: Follow-up context with incremental changes
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and verdict
|
||||
"""
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
35,
|
||||
"Parallel orchestrator analyzing follow-up...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Build orchestrator prompt
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Get project root
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Using model={model}, "
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_parallel",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
agents=self._define_specialist_agents(),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": ParallelFollowupResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
print(
|
||||
f"[ParallelFollowup] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelFollowup",
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
50,
|
||||
"Synthesizing follow-up findings...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output
|
||||
if structured_output:
|
||||
result_data = self._parse_structured_output(structured_output, context)
|
||||
else:
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
|
||||
# Extract data
|
||||
findings = result_data.get("findings", [])
|
||||
resolved_ids = result_data.get("resolved_ids", [])
|
||||
unresolved_ids = result_data.get("unresolved_ids", [])
|
||||
new_finding_ids = result_data.get("new_finding_ids", [])
|
||||
verdict = result_data.get("verdict", MergeVerdict.NEEDS_REVISION)
|
||||
verdict_reasoning = result_data.get("verdict_reasoning", "")
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
agents_from_result = result_data.get("agents_invoked", [])
|
||||
final_agents = agents_from_result if agents_from_result else agents_invoked
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
print(
|
||||
f"[ParallelFollowup] Complete. Agents invoked: {final_agents}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Deduplicate findings
|
||||
unique_findings = self._deduplicate_findings(findings)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Review complete: {len(unique_findings)} findings, "
|
||||
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary = self._generate_summary(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
resolved_count=len(resolved_ids),
|
||||
unresolved_count=len(unresolved_ids),
|
||||
new_count=len(new_finding_ids),
|
||||
agents_invoked=final_agents,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
if verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
overall_status = "comment"
|
||||
else:
|
||||
overall_status = "approve"
|
||||
|
||||
# Generate blockers from critical/high/medium severity findings
|
||||
# (Medium also blocks merge in our strict quality gates approach)
|
||||
blockers = []
|
||||
for finding in unique_findings:
|
||||
if finding.severity in (
|
||||
ReviewSeverity.CRITICAL,
|
||||
ReviewSeverity.HIGH,
|
||||
ReviewSeverity.MEDIUM,
|
||||
):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=unique_findings,
|
||||
summary=summary,
|
||||
overall_status=overall_status,
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
is_followup_review=True,
|
||||
previous_review_id=context.previous_review.review_id
|
||||
or context.previous_review.pr_number,
|
||||
resolved_findings=resolved_ids,
|
||||
unresolved_findings=unresolved_ids,
|
||||
new_findings_since_last_review=new_finding_ids,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzed",
|
||||
60,
|
||||
"Follow-up analysis complete",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ParallelFollowup] Review failed: {e}", exc_info=True)
|
||||
print(f"[ParallelFollowup] Error: {e}", flush=True)
|
||||
|
||||
return PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up review failed: {e}",
|
||||
overall_status="comment",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning=f"Review failed: {e}",
|
||||
blockers=[str(e)],
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
)
|
||||
|
||||
def _parse_structured_output(
|
||||
self, data: dict, context: FollowupReviewContext
|
||||
) -> dict:
|
||||
"""Parse structured output from ParallelFollowupResponse."""
|
||||
try:
|
||||
# Validate with Pydantic
|
||||
response = ParallelFollowupResponse.model_validate(data)
|
||||
|
||||
# Log agents from structured output
|
||||
agents_from_output = response.agents_invoked or []
|
||||
if agents_from_output:
|
||||
print(
|
||||
f"[ParallelFollowup] Specialist agents invoked: {', '.join(agents_from_output)}",
|
||||
flush=True,
|
||||
)
|
||||
for agent in agents_from_output:
|
||||
print(f"[Agent:{agent}] Analysis complete", flush=True)
|
||||
|
||||
findings = []
|
||||
resolved_ids = []
|
||||
unresolved_ids = []
|
||||
new_finding_ids = []
|
||||
|
||||
# Process resolution verifications
|
||||
for rv in response.resolution_verifications:
|
||||
if rv.status == "resolved":
|
||||
resolved_ids.append(rv.finding_id)
|
||||
elif rv.status in ("unresolved", "partially_resolved", "cant_verify"):
|
||||
# Include "cant_verify" as unresolved - if we can't verify, assume not fixed
|
||||
unresolved_ids.append(rv.finding_id)
|
||||
# Add unresolved as a finding
|
||||
if rv.status in ("unresolved", "cant_verify"):
|
||||
# Find original finding
|
||||
original = next(
|
||||
(
|
||||
f
|
||||
for f in context.previous_review.findings
|
||||
if f.id == rv.finding_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if original:
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=rv.finding_id,
|
||||
severity=original.severity,
|
||||
category=original.category,
|
||||
title=f"[UNRESOLVED] {original.title}",
|
||||
description=f"{original.description}\n\nResolution note: {rv.evidence}",
|
||||
file=original.file,
|
||||
line=original.line,
|
||||
suggested_fix=original.suggested_fix,
|
||||
fixable=original.fixable,
|
||||
)
|
||||
)
|
||||
|
||||
# Process new findings
|
||||
for nf in response.new_findings:
|
||||
finding_id = nf.id or self._generate_finding_id(
|
||||
nf.file, nf.line, nf.title
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(nf.severity),
|
||||
category=map_category(nf.category),
|
||||
title=nf.title,
|
||||
description=nf.description,
|
||||
file=nf.file,
|
||||
line=nf.line,
|
||||
suggested_fix=nf.suggested_fix,
|
||||
fixable=nf.fixable,
|
||||
)
|
||||
)
|
||||
|
||||
# Process comment findings
|
||||
for cf in response.comment_findings:
|
||||
finding_id = cf.id or self._generate_finding_id(
|
||||
cf.file, cf.line, cf.title
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(cf.severity),
|
||||
category=map_category(cf.category),
|
||||
title=f"[FROM COMMENTS] {cf.title}",
|
||||
description=cf.description,
|
||||
file=cf.file,
|
||||
line=cf.line,
|
||||
suggested_fix=cf.suggested_fix,
|
||||
fixable=cf.fixable,
|
||||
)
|
||||
)
|
||||
|
||||
# Map verdict
|
||||
verdict_map = {
|
||||
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
|
||||
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
|
||||
"BLOCKED": MergeVerdict.BLOCKED,
|
||||
}
|
||||
verdict = verdict_map.get(response.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
# Log findings summary for verification
|
||||
print(
|
||||
f"[ParallelFollowup] Parsed {len(findings)} findings, "
|
||||
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved, "
|
||||
f"{len(new_finding_ids)} new",
|
||||
flush=True,
|
||||
)
|
||||
if findings:
|
||||
print("[ParallelFollowup] Findings summary:", flush=True)
|
||||
for i, f in enumerate(findings, 1):
|
||||
print(
|
||||
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": resolved_ids,
|
||||
"unresolved_ids": unresolved_ids,
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": response.verdict_reasoning,
|
||||
"agents_invoked": agents_from_output,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[ParallelFollowup] Failed to parse structured output: {e}")
|
||||
return self._create_empty_result()
|
||||
|
||||
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
|
||||
"""Parse text output when structured output fails."""
|
||||
logger.warning("[ParallelFollowup] Falling back to text parsing")
|
||||
|
||||
# Simple heuristic parsing
|
||||
findings = []
|
||||
|
||||
# Look for verdict keywords
|
||||
text_lower = text.lower()
|
||||
if "ready to merge" in text_lower or "approve" in text_lower:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
elif "blocked" in text_lower or "critical" in text_lower:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": text[:500] if text else "Unable to parse response",
|
||||
}
|
||||
|
||||
def _create_empty_result(self) -> dict:
|
||||
"""Create empty result structure."""
|
||||
return {
|
||||
"findings": [],
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"verdict": MergeVerdict.NEEDS_REVISION,
|
||||
"verdict_reasoning": "Unable to parse review results",
|
||||
}
|
||||
|
||||
def _generate_finding_id(self, file: str, line: int, title: str) -> str:
|
||||
"""Generate a unique finding ID."""
|
||||
content = f"{file}:{line}:{title}"
|
||||
return f"FU-{hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()}"
|
||||
|
||||
def _deduplicate_findings(
|
||||
self, findings: list[PRReviewFinding]
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Remove duplicate findings."""
|
||||
seen = set()
|
||||
unique = []
|
||||
for f in findings:
|
||||
key = (f.file, f.line, f.title.lower().strip())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
return unique
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
verdict_reasoning: str,
|
||||
resolved_count: int,
|
||||
unresolved_count: int,
|
||||
new_count: int,
|
||||
agents_invoked: list[str],
|
||||
) -> str:
|
||||
"""Generate a human-readable summary of the follow-up review."""
|
||||
status_emoji = {
|
||||
MergeVerdict.READY_TO_MERGE: "✅",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "⚠️",
|
||||
MergeVerdict.NEEDS_REVISION: "🔄",
|
||||
MergeVerdict.BLOCKED: "🚫",
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(verdict, "📝")
|
||||
agents_str = (
|
||||
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
|
||||
)
|
||||
|
||||
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
|
||||
|
||||
### Resolution Status
|
||||
- ✅ **Resolved**: {resolved_count} previous findings addressed
|
||||
- ❌ **Unresolved**: {unresolved_count} previous findings remain
|
||||
- 🆕 **New Issues**: {new_count} new findings in recent changes
|
||||
|
||||
### Verdict
|
||||
{verdict_reasoning}
|
||||
|
||||
### Review Process
|
||||
Agents invoked: {agents_str}
|
||||
|
||||
---
|
||||
*This is an AI-generated follow-up review using parallel specialist analysis.*
|
||||
"""
|
||||
return summary
|
||||
@@ -0,0 +1,808 @@
|
||||
"""
|
||||
Parallel Orchestrator PR Reviewer
|
||||
==================================
|
||||
|
||||
PR reviewer using Claude Agent SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator analyzes the PR and delegates to specialized agents (security,
|
||||
quality, logic, codebase-fit, ai-triage) which run in parallel. Results are
|
||||
synthesized into a final verdict.
|
||||
|
||||
Key Design:
|
||||
- AI decides which agents to invoke (NOT programmatic rules)
|
||||
- Subagents defined via SDK `agents={}` parameter
|
||||
- SDK handles parallel execution automatically
|
||||
- User-configured model from frontend settings (no hardcoding)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .pydantic_models import ParallelOrchestratorResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext
|
||||
from core.client import create_client
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.category_utils import map_category
|
||||
from services.pydantic_models import ParallelOrchestratorResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
class ParallelOrchestratorReviewer:
|
||||
"""
|
||||
PR reviewer using SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator:
|
||||
1. Analyzes the PR (size, complexity, file types, risk areas)
|
||||
2. Delegates to appropriate specialist agents (SDK handles parallel execution)
|
||||
3. Synthesizes findings into a final verdict
|
||||
|
||||
Model Configuration:
|
||||
- Orchestrator uses user-configured model from frontend settings
|
||||
- Specialist agents use model="inherit" (same as orchestrator)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
config: GitHubRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""Load a prompt file from the prompts/github directory."""
|
||||
prompt_file = (
|
||||
Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename
|
||||
)
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
Define specialist agents for the SDK.
|
||||
|
||||
Each agent has:
|
||||
- description: When the orchestrator should invoke this agent
|
||||
- prompt: System prompt for the agent
|
||||
- tools: Tools the agent can use (read-only for PR review)
|
||||
- model: "inherit" = use same model as orchestrator (user's choice)
|
||||
|
||||
Returns AgentDefinition dataclass instances as required by the SDK.
|
||||
"""
|
||||
# Load agent prompts from files
|
||||
security_prompt = self._load_prompt("pr_security_agent.md")
|
||||
quality_prompt = self._load_prompt("pr_quality_agent.md")
|
||||
logic_prompt = self._load_prompt("pr_logic_agent.md")
|
||||
codebase_fit_prompt = self._load_prompt("pr_codebase_fit_agent.md")
|
||||
ai_triage_prompt = self._load_prompt("pr_ai_triage.md")
|
||||
|
||||
return {
|
||||
"security-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"Security specialist. Use for OWASP Top 10, authentication, "
|
||||
"injection, cryptographic issues, and sensitive data exposure. "
|
||||
"Invoke when PR touches auth, API endpoints, user input, database queries, "
|
||||
"or file operations."
|
||||
),
|
||||
prompt=security_prompt
|
||||
or "You are a security expert. Find vulnerabilities.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"quality-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"Code quality expert. Use for complexity, duplication, error handling, "
|
||||
"maintainability, and pattern adherence. Invoke when PR has complex logic, "
|
||||
"large functions, or significant business logic changes."
|
||||
),
|
||||
prompt=quality_prompt
|
||||
or "You are a code quality expert. Find quality issues.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"logic-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"Logic and correctness specialist. Use for algorithm verification, "
|
||||
"edge cases, state management, and race conditions. Invoke when PR has "
|
||||
"algorithmic changes, data transformations, concurrent operations, or bug fixes."
|
||||
),
|
||||
prompt=logic_prompt
|
||||
or "You are a logic expert. Find correctness issues.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"codebase-fit-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"Codebase consistency expert. Use for naming conventions, ecosystem fit, "
|
||||
"architectural alignment, and avoiding reinvention. Invoke when PR introduces "
|
||||
"new patterns, large additions, or code that might duplicate existing functionality."
|
||||
),
|
||||
prompt=codebase_fit_prompt
|
||||
or "You are a codebase expert. Check for consistency.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"ai-triage-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"AI comment validator. Use for triaging comments from CodeRabbit, "
|
||||
"Gemini Code Assist, Cursor, Greptile, and other AI reviewers. "
|
||||
"Invoke when PR has existing AI review comments that need validation."
|
||||
),
|
||||
prompt=ai_triage_prompt
|
||||
or "You are an AI triage expert. Validate AI comments.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
}
|
||||
|
||||
def _build_orchestrator_prompt(self, context: PRContext) -> str:
|
||||
"""Build full prompt for orchestrator with PR context."""
|
||||
# Load orchestrator prompt
|
||||
base_prompt = self._load_prompt("pr_parallel_orchestrator.md")
|
||||
if not base_prompt:
|
||||
base_prompt = "You are a PR reviewer. Analyze and delegate to specialists."
|
||||
|
||||
# Build file list
|
||||
files_list = []
|
||||
for file in context.changed_files:
|
||||
files_list.append(
|
||||
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
|
||||
)
|
||||
|
||||
# Build composite diff
|
||||
patches = []
|
||||
MAX_DIFF_CHARS = 200_000
|
||||
|
||||
for file in context.changed_files:
|
||||
if file.patch:
|
||||
patches.append(f"\n### File: {file.path}\n{file.patch}")
|
||||
|
||||
diff_content = "\n".join(patches)
|
||||
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
# Build AI comments context if present
|
||||
ai_comments_section = ""
|
||||
if context.ai_bot_comments:
|
||||
ai_comments_list = []
|
||||
for comment in context.ai_bot_comments[:20]:
|
||||
ai_comments_list.append(
|
||||
f"- **{comment.tool_name}** on {comment.file or 'general'}: "
|
||||
f"{comment.body[:200]}..."
|
||||
)
|
||||
ai_comments_section = f"""
|
||||
### AI Review Comments (need triage)
|
||||
Found {len(context.ai_bot_comments)} comments from AI tools:
|
||||
{chr(10).join(ai_comments_list)}
|
||||
"""
|
||||
|
||||
pr_context = f"""
|
||||
---
|
||||
|
||||
## PR Context for Review
|
||||
|
||||
**PR Number:** {context.pr_number}
|
||||
**Title:** {context.title}
|
||||
**Author:** {context.author}
|
||||
**Base:** {context.base_branch} ← **Head:** {context.head_branch}
|
||||
**Files Changed:** {len(context.changed_files)} files
|
||||
**Total Changes:** +{context.total_additions}/-{context.total_deletions} lines
|
||||
|
||||
### Description
|
||||
{context.description}
|
||||
|
||||
### All Changed Files
|
||||
{chr(10).join(files_list)}
|
||||
{ai_comments_section}
|
||||
### Code Changes
|
||||
```diff
|
||||
{diff_content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Now analyze this PR and delegate to the appropriate specialist agents.
|
||||
Remember: YOU decide which agents to invoke based on YOUR analysis.
|
||||
The SDK will run invoked agents in parallel automatically.
|
||||
"""
|
||||
|
||||
return base_prompt + pr_context
|
||||
|
||||
def _create_sdk_client(
|
||||
self, project_root: Path, model: str, thinking_budget: int | None
|
||||
):
|
||||
"""Create SDK client with subagents and configuration.
|
||||
|
||||
Args:
|
||||
project_root: Root directory of the project
|
||||
model: Model to use for orchestrator
|
||||
thinking_budget: Max thinking tokens budget
|
||||
|
||||
Returns:
|
||||
Configured SDK client instance
|
||||
"""
|
||||
return create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_orchestrator_parallel",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
agents=self._define_specialist_agents(),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": ParallelOrchestratorResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_structured_output(
|
||||
self, structured_output: dict[str, Any] | None, result_text: str
|
||||
) -> tuple[list[PRReviewFinding], list[str]]:
|
||||
"""Parse and extract findings from structured output or text fallback.
|
||||
|
||||
Args:
|
||||
structured_output: Structured JSON output from agent
|
||||
result_text: Raw text output as fallback
|
||||
|
||||
Returns:
|
||||
Tuple of (findings list, agents_invoked list)
|
||||
"""
|
||||
agents_from_structured: list[str] = []
|
||||
|
||||
if structured_output:
|
||||
findings, agents_from_structured = self._parse_structured_output(
|
||||
structured_output
|
||||
)
|
||||
if findings is None and result_text:
|
||||
findings = self._parse_text_output(result_text)
|
||||
elif findings is None:
|
||||
findings = []
|
||||
else:
|
||||
findings = self._parse_text_output(result_text)
|
||||
|
||||
return findings, agents_from_structured
|
||||
|
||||
def _log_agents_invoked(self, agents: list[str]) -> None:
|
||||
"""Log invoked agents with clear formatting.
|
||||
|
||||
Args:
|
||||
agents: List of agent names that were invoked
|
||||
"""
|
||||
if agents:
|
||||
print(
|
||||
f"[ParallelOrchestrator] Specialist agents invoked: {', '.join(agents)}",
|
||||
flush=True,
|
||||
)
|
||||
for agent in agents:
|
||||
print(f"[Agent:{agent}] Analysis complete", flush=True)
|
||||
|
||||
def _log_findings_summary(self, findings: list[PRReviewFinding]) -> None:
|
||||
"""Log findings summary for verification.
|
||||
|
||||
Args:
|
||||
findings: List of findings to summarize
|
||||
"""
|
||||
if findings:
|
||||
print(
|
||||
f"[ParallelOrchestrator] Parsed {len(findings)} findings from structured output",
|
||||
flush=True,
|
||||
)
|
||||
print("[ParallelOrchestrator] Findings summary:", flush=True)
|
||||
for i, f in enumerate(findings, 1):
|
||||
print(
|
||||
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _create_finding_from_structured(self, finding_data: Any) -> PRReviewFinding:
|
||||
"""Create a PRReviewFinding from structured output data.
|
||||
|
||||
Args:
|
||||
finding_data: Finding data from structured output
|
||||
|
||||
Returns:
|
||||
PRReviewFinding instance
|
||||
"""
|
||||
finding_id = hashlib.md5(
|
||||
f"{finding_data.file}:{finding_data.line}:{finding_data.title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(finding_data.category)
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(finding_data.severity.lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
return PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=finding_data.file,
|
||||
line=finding_data.line,
|
||||
title=finding_data.title,
|
||||
description=finding_data.description,
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=finding_data.suggested_fix or "",
|
||||
confidence=self._normalize_confidence(finding_data.confidence),
|
||||
)
|
||||
|
||||
async def review(self, context: PRContext) -> PRReviewResult:
|
||||
"""
|
||||
Main review entry point.
|
||||
|
||||
Args:
|
||||
context: Full PR context with all files and patches
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and verdict
|
||||
"""
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Starting review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
35,
|
||||
"Parallel orchestrator analyzing PR...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Build orchestrator prompt
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Get project root
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Using model={model}, "
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
# SDK handles parallel execution when Claude invokes multiple Task tools
|
||||
client = self._create_sdk_client(project_root, model, thinking_budget)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
50,
|
||||
"Synthesizing findings...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (structured output also returns agents)
|
||||
findings, agents_from_structured = self._extract_structured_output(
|
||||
structured_output, result_text
|
||||
)
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
final_agents = (
|
||||
agents_from_structured if agents_from_structured else agents_invoked
|
||||
)
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
print(
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Deduplicate findings
|
||||
unique_findings = self._deduplicate_findings(findings)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings"
|
||||
)
|
||||
|
||||
# Generate verdict
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
unique_findings
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary = self._generate_summary(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
agents_invoked=final_agents,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
if verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
overall_status = "comment"
|
||||
else:
|
||||
overall_status = "approve"
|
||||
|
||||
# Extract HEAD SHA from commits for follow-up review tracking
|
||||
head_sha = None
|
||||
if context.commits:
|
||||
latest_commit = context.commits[-1]
|
||||
head_sha = latest_commit.get("oid") or latest_commit.get("sha")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=unique_findings,
|
||||
summary=summary,
|
||||
overall_status=overall_status,
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=head_sha,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzed",
|
||||
60,
|
||||
"Parallel analysis complete",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ParallelOrchestrator] Review failed: {e}", exc_info=True)
|
||||
return PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _parse_structured_output(
|
||||
self, structured_output: dict[str, Any]
|
||||
) -> tuple[list[PRReviewFinding] | None, list[str]]:
|
||||
"""Parse findings and agents from SDK structured output.
|
||||
|
||||
Returns:
|
||||
Tuple of (findings list or None if parsing failed, agents list)
|
||||
"""
|
||||
findings = []
|
||||
agents_from_output: list[str] = []
|
||||
|
||||
try:
|
||||
result = ParallelOrchestratorResponse.model_validate(structured_output)
|
||||
agents_from_output = result.agents_invoked or []
|
||||
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Structured output: verdict={result.verdict}, "
|
||||
f"{len(result.findings)} findings, agents={agents_from_output}"
|
||||
)
|
||||
|
||||
# Log agents invoked with clear formatting
|
||||
self._log_agents_invoked(agents_from_output)
|
||||
|
||||
# Convert structured findings to PRReviewFinding objects
|
||||
for f in result.findings:
|
||||
finding = self._create_finding_from_structured(f)
|
||||
findings.append(finding)
|
||||
|
||||
# Log findings summary for verification
|
||||
self._log_findings_summary(findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] Structured output parsing failed: {e}"
|
||||
)
|
||||
return None, agents_from_output
|
||||
|
||||
return findings, agents_from_output
|
||||
|
||||
def _extract_json_from_text(self, output: str) -> dict[str, Any] | None:
|
||||
"""Extract JSON object from text output.
|
||||
|
||||
Args:
|
||||
output: Text output to parse
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None if not found
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
# Try to find JSON in code blocks
|
||||
code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```"
|
||||
code_block_match = re.search(code_block_pattern, output)
|
||||
|
||||
if code_block_match:
|
||||
json_str = code_block_match.group(1)
|
||||
return json.loads(json_str)
|
||||
|
||||
# Try to find raw JSON object
|
||||
start = output.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
brace_count = 0
|
||||
end = -1
|
||||
for i in range(start, len(output)):
|
||||
if output[i] == "{":
|
||||
brace_count += 1
|
||||
elif output[i] == "}":
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
end = i
|
||||
break
|
||||
|
||||
if end != -1:
|
||||
json_str = output[start : end + 1]
|
||||
return json.loads(json_str)
|
||||
|
||||
return None
|
||||
|
||||
def _create_finding_from_dict(self, f_data: dict[str, Any]) -> PRReviewFinding:
|
||||
"""Create a PRReviewFinding from dictionary data.
|
||||
|
||||
Args:
|
||||
f_data: Finding data as dictionary
|
||||
|
||||
Returns:
|
||||
PRReviewFinding instance
|
||||
"""
|
||||
finding_id = hashlib.md5(
|
||||
f"{f_data.get('file', 'unknown')}:{f_data.get('line', 0)}:{f_data.get('title', 'Untitled')}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f_data.get("category", "quality"))
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(f_data.get("severity", "medium").lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
return PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=f_data.get("file", "unknown"),
|
||||
line=f_data.get("line", 0),
|
||||
title=f_data.get("title", "Untitled"),
|
||||
description=f_data.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f_data.get("suggested_fix", ""),
|
||||
confidence=self._normalize_confidence(f_data.get("confidence", 85)),
|
||||
)
|
||||
|
||||
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from text output (fallback)."""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Extract JSON from text
|
||||
data = self._extract_json_from_text(output)
|
||||
if not data:
|
||||
return findings
|
||||
|
||||
# Get findings array from JSON
|
||||
findings_data = data.get("findings", [])
|
||||
|
||||
# Convert each finding dict to PRReviewFinding
|
||||
for f_data in findings_data:
|
||||
finding = self._create_finding_from_dict(f_data)
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ParallelOrchestrator] Text parsing failed: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _normalize_confidence(self, value: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if value > 1:
|
||||
return value / 100.0
|
||||
return float(value)
|
||||
|
||||
def _deduplicate_findings(
|
||||
self, findings: list[PRReviewFinding]
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Remove duplicate findings."""
|
||||
seen = set()
|
||||
unique = []
|
||||
|
||||
for f in findings:
|
||||
key = (f.file, f.line, f.title.lower().strip())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
|
||||
return unique
|
||||
|
||||
def _generate_verdict(
|
||||
self, findings: list[PRReviewFinding]
|
||||
) -> tuple[MergeVerdict, str, list[str]]:
|
||||
"""Generate merge verdict based on findings."""
|
||||
blockers = []
|
||||
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM]
|
||||
low = [f for f in findings if f.severity == ReviewSeverity.LOW]
|
||||
|
||||
for f in critical:
|
||||
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
|
||||
|
||||
if blockers:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(blockers)} critical issue(s)"
|
||||
elif high or medium:
|
||||
# High and Medium severity findings block merge
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
total = len(high) + len(medium)
|
||||
reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)"
|
||||
if low:
|
||||
reasoning += f", {len(low)} suggestions"
|
||||
elif low:
|
||||
# Only Low severity suggestions - can merge but consider addressing
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = f"{len(low)} suggestion(s) to consider"
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
reasoning = "No blocking issues found"
|
||||
|
||||
return verdict, reasoning, blockers
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
verdict_reasoning: str,
|
||||
blockers: list[str],
|
||||
findings: list[PRReviewFinding],
|
||||
agents_invoked: list[str],
|
||||
) -> str:
|
||||
"""Generate PR review summary."""
|
||||
verdict_emoji = {
|
||||
MergeVerdict.READY_TO_MERGE: "✅",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
|
||||
MergeVerdict.NEEDS_REVISION: "🟠",
|
||||
MergeVerdict.BLOCKED: "🔴",
|
||||
}
|
||||
|
||||
lines = [
|
||||
f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}",
|
||||
verdict_reasoning,
|
||||
"",
|
||||
]
|
||||
|
||||
# Agents used
|
||||
if agents_invoked:
|
||||
lines.append(f"**Specialist Agents Invoked:** {', '.join(agents_invoked)}")
|
||||
lines.append("")
|
||||
|
||||
# Blockers
|
||||
if blockers:
|
||||
lines.append("### 🚨 Blocking Issues")
|
||||
for blocker in blockers:
|
||||
lines.append(f"- {blocker}")
|
||||
lines.append("")
|
||||
|
||||
# Findings summary
|
||||
if findings:
|
||||
by_severity: dict[str, list] = {}
|
||||
for f in findings:
|
||||
severity = f.severity.value
|
||||
if severity not in by_severity:
|
||||
by_severity[severity] = []
|
||||
by_severity[severity].append(f)
|
||||
|
||||
lines.append("### Findings Summary")
|
||||
for severity in ["critical", "high", "medium", "low"]:
|
||||
if severity in by_severity:
|
||||
count = len(by_severity[severity])
|
||||
lines.append(f"- **{severity.capitalize()}**: {count} issue(s)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("_Generated by Auto Claude Parallel Orchestrator (SDK Subagents)_")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -277,19 +277,22 @@ class PRReviewEngine:
|
||||
Returns:
|
||||
Tuple of (findings, structural_issues, ai_triages, quick_scan_summary)
|
||||
"""
|
||||
# Use orchestrating agent if enabled
|
||||
if self.config.use_orchestrator_review:
|
||||
print("[AI] Using orchestrating PR review agent (Opus 4.5)...", flush=True)
|
||||
# Use parallel orchestrator with SDK subagents if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
"[AI] Using parallel orchestrator PR review (SDK subagents)...",
|
||||
flush=True,
|
||||
)
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
10,
|
||||
"Starting orchestrating review...",
|
||||
"Starting parallel orchestrator review...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
from .orchestrator_reviewer import OrchestratorReviewer
|
||||
from .parallel_orchestrator_reviewer import ParallelOrchestratorReviewer
|
||||
|
||||
orchestrator = OrchestratorReviewer(
|
||||
orchestrator = ParallelOrchestratorReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
@@ -299,22 +302,16 @@ class PRReviewEngine:
|
||||
result = await orchestrator.review(context)
|
||||
|
||||
print(
|
||||
f"[PR Review Engine] Orchestrator returned {len(result.findings)} findings",
|
||||
f"[PR Review Engine] Parallel orchestrator returned {len(result.findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Convert PRReviewResult to expected format
|
||||
# Orchestrator doesn't use structural_issues or ai_triages
|
||||
quick_scan_summary = {
|
||||
"verdict": result.verdict.value if result.verdict else "unknown",
|
||||
"findings_count": len(result.findings),
|
||||
"strategy": "orchestrating_agent",
|
||||
"strategy": "parallel_orchestrator",
|
||||
}
|
||||
|
||||
print(
|
||||
f"[PR Review Engine] Returning tuple with {len(result.findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
return (result.findings, [], [], quick_scan_summary)
|
||||
|
||||
# Fall back to multi-pass review
|
||||
|
||||
@@ -268,7 +268,7 @@ Output JSON array of structural issues:
|
||||
```
|
||||
""",
|
||||
ReviewPass.AI_COMMENT_TRIAGE: """
|
||||
You are triaging comments from other AI code review tools (CodeRabbit, Cursor, Greptile, etc).
|
||||
You are triaging comments from other AI code review tools (CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc).
|
||||
|
||||
For each AI comment, determine:
|
||||
- CRITICAL: Genuine issue that must be addressed before merge
|
||||
|
||||
@@ -342,3 +342,279 @@ class OrchestratorReviewResponse(BaseModel):
|
||||
default_factory=list, description="Issues found during review"
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the review")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Orchestrator Review Response (SDK Subagents)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LogicFinding(BaseFinding):
|
||||
"""A logic/correctness finding from the logic review agent."""
|
||||
|
||||
category: Literal["logic"] = Field(
|
||||
default="logic", description="Always 'logic' for logic findings"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
example_input: str | None = Field(
|
||||
None, description="Concrete input that triggers the bug"
|
||||
)
|
||||
actual_output: str | None = Field(None, description="What the buggy code produces")
|
||||
expected_output: str | None = Field(
|
||||
None, description="What the code should produce"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class CodebaseFitFinding(BaseFinding):
|
||||
"""A codebase fit finding from the codebase fit review agent."""
|
||||
|
||||
category: Literal["codebase_fit"] = Field(
|
||||
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
existing_code: str | None = Field(
|
||||
None, description="Reference to existing code that should be used instead"
|
||||
)
|
||||
codebase_pattern: str | None = Field(
|
||||
None, description="Description of the established pattern being violated"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class ParallelOrchestratorFinding(BaseModel):
|
||||
"""A finding from the parallel orchestrator with source agent tracking."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
source_agents: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Which agents reported this finding",
|
||||
)
|
||||
cross_validated: bool = Field(
|
||||
False, description="Whether multiple agents agreed on this finding"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class AgentAgreement(BaseModel):
|
||||
"""Tracks agreement between agents on findings."""
|
||||
|
||||
agreed_findings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Finding IDs that multiple agents agreed on",
|
||||
)
|
||||
conflicting_findings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Finding IDs where agents disagreed",
|
||||
)
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Notes on how conflicts were resolved"
|
||||
)
|
||||
|
||||
|
||||
class ParallelOrchestratorResponse(BaseModel):
|
||||
"""Complete response schema for parallel orchestrator PR review."""
|
||||
|
||||
analysis_summary: str = Field(
|
||||
description="Brief summary of what was analyzed and why agents were chosen"
|
||||
)
|
||||
agents_invoked: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of agent names that were invoked",
|
||||
)
|
||||
findings: list[ParallelOrchestratorFinding] = Field(
|
||||
default_factory=list, description="All findings from synthesis"
|
||||
)
|
||||
agent_agreement: AgentAgreement = Field(
|
||||
default_factory=AgentAgreement,
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
|
||||
description="Overall PR verdict"
|
||||
)
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ResolutionVerification(BaseModel):
|
||||
"""AI-verified resolution status for a previous finding."""
|
||||
|
||||
finding_id: str = Field(description="ID of the previous finding")
|
||||
status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = (
|
||||
Field(description="Resolution status after AI verification")
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in the resolution status"
|
||||
)
|
||||
evidence: str = Field(description="What evidence supports this resolution status")
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Detailed notes on how the issue was addressed"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class ParallelFollowupFinding(BaseModel):
|
||||
"""A finding from parallel follow-up review with source agent tracking."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
source_agent: str = Field(
|
||||
description="Which agent reported this finding (resolution/newcode/comment)"
|
||||
)
|
||||
related_to_previous: str | None = Field(
|
||||
None, description="ID of related previous finding if this is a regression"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class CommentAnalysis(BaseModel):
|
||||
"""Analysis of a contributor or AI comment."""
|
||||
|
||||
comment_id: str = Field(description="Identifier for the comment")
|
||||
author: str = Field(description="Comment author")
|
||||
is_ai_bot: bool = Field(description="Whether this is from an AI tool")
|
||||
requires_response: bool = Field(description="Whether this comment needs a response")
|
||||
sentiment: Literal["question", "concern", "suggestion", "praise", "neutral"] = (
|
||||
Field(description="Comment sentiment/type")
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the comment")
|
||||
action_needed: str | None = Field(None, description="What action is needed if any")
|
||||
|
||||
|
||||
class ParallelFollowupResponse(BaseModel):
|
||||
"""Complete response schema for parallel follow-up PR review."""
|
||||
|
||||
# Analysis metadata
|
||||
analysis_summary: str = Field(
|
||||
description="Brief summary of what was analyzed in this follow-up"
|
||||
)
|
||||
agents_invoked: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of agent names that were invoked",
|
||||
)
|
||||
commits_analyzed: int = Field(0, description="Number of new commits analyzed")
|
||||
files_changed: int = Field(
|
||||
0, description="Number of files changed since last review"
|
||||
)
|
||||
|
||||
# Resolution verification (from resolution-verifier agent)
|
||||
resolution_verifications: list[ResolutionVerification] = Field(
|
||||
default_factory=list,
|
||||
description="AI-verified resolution status for each previous finding",
|
||||
)
|
||||
|
||||
# New findings (from new-code-reviewer agent)
|
||||
new_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="New issues found in changes since last review",
|
||||
)
|
||||
|
||||
# Comment analysis (from comment-analyzer agent)
|
||||
comment_analyses: list[CommentAnalysis] = Field(
|
||||
default_factory=list,
|
||||
description="Analysis of contributor and AI comments",
|
||||
)
|
||||
comment_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues identified from comment analysis",
|
||||
)
|
||||
|
||||
# Agent agreement tracking
|
||||
agent_agreement: AgentAgreement = Field(
|
||||
default_factory=AgentAgreement,
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
|
||||
# Verdict
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
@@ -18,50 +18,20 @@ try:
|
||||
from ...analysis.test_discovery import TestDiscovery
|
||||
from ...core.client import create_client
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
from ..models import PRReviewFinding, ReviewSeverity
|
||||
from .category_utils import map_category
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from analysis.test_discovery import TestDiscovery
|
||||
from category_utils import map_category
|
||||
from context_gatherer import PRContext
|
||||
from core.client import create_client
|
||||
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
from models import PRReviewFinding, ReviewSeverity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map AI-generated category names to valid ReviewCategory enum values
|
||||
_CATEGORY_MAPPING = {
|
||||
# Direct matches
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
# AI-generated alternatives
|
||||
"correctness": ReviewCategory.QUALITY,
|
||||
"consistency": ReviewCategory.PATTERN,
|
||||
"testing": ReviewCategory.TEST,
|
||||
"documentation": ReviewCategory.DOCS,
|
||||
"bug": ReviewCategory.QUALITY,
|
||||
"logic": ReviewCategory.QUALITY,
|
||||
"error_handling": ReviewCategory.QUALITY,
|
||||
"maintainability": ReviewCategory.QUALITY,
|
||||
"readability": ReviewCategory.STYLE,
|
||||
"best_practices": ReviewCategory.PATTERN,
|
||||
"architecture": ReviewCategory.PATTERN,
|
||||
"complexity": ReviewCategory.QUALITY,
|
||||
"dead_code": ReviewCategory.REDUNDANCY,
|
||||
"unused": ReviewCategory.REDUNDANCY,
|
||||
}
|
||||
|
||||
|
||||
def _map_category(category_str: str) -> ReviewCategory:
|
||||
"""Map an AI-generated category string to a valid ReviewCategory enum."""
|
||||
normalized = category_str.lower().strip().replace("-", "_")
|
||||
return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
|
||||
# Use shared category mapping from category_utils
|
||||
_map_category = map_category
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
SDK Stream Processing Utilities
|
||||
================================
|
||||
|
||||
Shared utilities for processing Claude Agent SDK response streams.
|
||||
|
||||
This module extracts common SDK message processing patterns used across
|
||||
parallel orchestrator and follow-up reviewers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
async def process_sdk_stream(
|
||||
client: Any,
|
||||
on_thinking: Callable[[str], None] | None = None,
|
||||
on_tool_use: Callable[[str, str, dict[str, Any]], None] | None = None,
|
||||
on_tool_result: Callable[[str, bool, Any], None] | None = None,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
|
||||
context_name: str = "SDK",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process SDK response stream with customizable callbacks.
|
||||
|
||||
This function handles the common pattern of:
|
||||
- Tracking thinking blocks
|
||||
- Tracking tool invocations (especially Task/subagent calls)
|
||||
- Tracking tool results
|
||||
- Collecting text output
|
||||
- Extracting structured output
|
||||
|
||||
Args:
|
||||
client: Claude SDK client with receive_response() method
|
||||
on_thinking: Callback for thinking blocks - receives thinking text
|
||||
on_tool_use: Callback for tool invocations - receives (tool_name, tool_id, tool_input)
|
||||
on_tool_result: Callback for tool results - receives (tool_id, is_error, result_content)
|
||||
on_text: Callback for text output - receives text string
|
||||
on_structured_output: Callback for structured output - receives dict
|
||||
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- result_text: Accumulated text output
|
||||
- structured_output: Final structured output (if any)
|
||||
- agents_invoked: List of agent names invoked via Task tool
|
||||
- msg_count: Total message count
|
||||
- subagent_tool_ids: Mapping of tool_id -> agent_name
|
||||
- error: Error message if stream processing failed (None on success)
|
||||
"""
|
||||
result_text = ""
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
stream_error = None
|
||||
# Track subagent tool IDs to log their results
|
||||
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
|
||||
print(f"[{context_name}] Processing SDK stream...", flush=True)
|
||||
if DEBUG_MODE:
|
||||
print(f"[DEBUG {context_name}] Awaiting response stream...", flush=True)
|
||||
|
||||
try:
|
||||
async for msg in client.receive_response():
|
||||
try:
|
||||
msg_type = type(msg).__name__
|
||||
msg_count += 1
|
||||
|
||||
if DEBUG_MODE:
|
||||
# Log every message type for visibility
|
||||
msg_details = ""
|
||||
if hasattr(msg, "type"):
|
||||
msg_details = f" (type={msg.type})"
|
||||
print(
|
||||
f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Track thinking blocks
|
||||
if msg_type == "ThinkingBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "thinking"
|
||||
):
|
||||
thinking_text = getattr(msg, "thinking", "") or getattr(
|
||||
msg, "text", ""
|
||||
)
|
||||
if thinking_text:
|
||||
print(
|
||||
f"[{context_name}] AI thinking: {len(thinking_text)} chars",
|
||||
flush=True,
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
# Show first 200 chars of thinking
|
||||
preview = thinking_text[:200].replace("\n", " ")
|
||||
print(
|
||||
f"[DEBUG {context_name}] Thinking preview: {preview}...",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_thinking:
|
||||
on_thinking(thinking_text)
|
||||
|
||||
# Track subagent invocations (Task tool calls)
|
||||
if msg_type == "ToolUseBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "tool_use"
|
||||
):
|
||||
tool_name = getattr(msg, "name", "")
|
||||
tool_id = getattr(msg, "id", "unknown")
|
||||
tool_input = getattr(msg, "input", {})
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if tool_name == "Task":
|
||||
# Extract which agent was invoked
|
||||
agent_name = tool_input.get("subagent_type", "unknown")
|
||||
agents_invoked.append(agent_name)
|
||||
# Track this tool ID to log its result later
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
print(
|
||||
f"[{context_name}] Invoked agent: {agent_name}", flush=True
|
||||
)
|
||||
elif tool_name == "StructuredOutput":
|
||||
if tool_input:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = tool_input
|
||||
print(
|
||||
f"[{context_name}] Received structured output",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(tool_input)
|
||||
elif DEBUG_MODE:
|
||||
# Log other tool calls in debug mode
|
||||
print(
|
||||
f"[DEBUG {context_name}] Other tool: {tool_name}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback for all tool uses
|
||||
if on_tool_use:
|
||||
on_tool_use(tool_name, tool_id, tool_input)
|
||||
|
||||
# Track tool results
|
||||
if msg_type == "ToolResultBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "tool_result"
|
||||
):
|
||||
tool_id = getattr(msg, "tool_use_id", "unknown")
|
||||
is_error = getattr(msg, "is_error", False)
|
||||
result_content = getattr(msg, "content", "")
|
||||
|
||||
# Handle list of content blocks
|
||||
if isinstance(result_content, list):
|
||||
result_content = " ".join(
|
||||
str(getattr(c, "text", c)) for c in result_content
|
||||
)
|
||||
|
||||
# Check if this is a subagent result
|
||||
if tool_id in subagent_tool_ids:
|
||||
agent_name = subagent_tool_ids[tool_id]
|
||||
status = "ERROR" if is_error else "complete"
|
||||
result_preview = (
|
||||
str(result_content)[:600].replace("\n", " ").strip()
|
||||
)
|
||||
print(
|
||||
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif DEBUG_MODE:
|
||||
status = "ERROR" if is_error else "OK"
|
||||
print(
|
||||
f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_result:
|
||||
on_tool_result(tool_id, is_error, result_content)
|
||||
|
||||
# Collect text output and check for tool uses in content blocks
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
# Check for tool use blocks within content
|
||||
if (
|
||||
block_type == "ToolUseBlock"
|
||||
or getattr(block, "type", "") == "tool_use"
|
||||
):
|
||||
tool_name = getattr(block, "name", "")
|
||||
tool_id = getattr(block, "id", "unknown")
|
||||
tool_input = getattr(block, "input", {})
|
||||
|
||||
if tool_name == "Task":
|
||||
agent_name = tool_input.get("subagent_type", "unknown")
|
||||
if agent_name not in agents_invoked:
|
||||
agents_invoked.append(agent_name)
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
print(
|
||||
f"[{context_name}] Invoking agent: {agent_name}",
|
||||
flush=True,
|
||||
)
|
||||
elif tool_name == "StructuredOutput":
|
||||
if tool_input:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = tool_input
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(tool_input)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_use:
|
||||
on_tool_use(tool_name, tool_id, tool_input)
|
||||
|
||||
# Collect text
|
||||
if hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Always print text content preview (not just in DEBUG_MODE)
|
||||
text_preview = block.text[:500].replace("\n", " ").strip()
|
||||
if text_preview:
|
||||
print(
|
||||
f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_text:
|
||||
on_text(block.text)
|
||||
|
||||
# Check for StructuredOutput in content (legacy check)
|
||||
if getattr(block, "name", "") == "StructuredOutput":
|
||||
structured_data = getattr(block, "input", None)
|
||||
if structured_data:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = structured_data
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(structured_data)
|
||||
|
||||
# Check for structured_output attribute
|
||||
if hasattr(msg, "structured_output") and msg.structured_output:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = msg.structured_output
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(msg.structured_output)
|
||||
|
||||
# Check for tool results in UserMessage (subagent results come back here)
|
||||
if msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
# Check for tool result blocks
|
||||
if (
|
||||
block_type == "ToolResultBlock"
|
||||
or getattr(block, "type", "") == "tool_result"
|
||||
):
|
||||
tool_id = getattr(block, "tool_use_id", "unknown")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
result_content = getattr(block, "content", "")
|
||||
|
||||
# Handle list of content blocks
|
||||
if isinstance(result_content, list):
|
||||
result_content = " ".join(
|
||||
str(getattr(c, "text", c)) for c in result_content
|
||||
)
|
||||
|
||||
# Check if this is a subagent result
|
||||
if tool_id in subagent_tool_ids:
|
||||
agent_name = subagent_tool_ids[tool_id]
|
||||
status = "ERROR" if is_error else "complete"
|
||||
result_preview = (
|
||||
str(result_content)[:600].replace("\n", " ").strip()
|
||||
)
|
||||
print(
|
||||
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_result:
|
||||
on_tool_result(tool_id, is_error, result_content)
|
||||
|
||||
except (AttributeError, TypeError, KeyError) as msg_error:
|
||||
# Log individual message processing errors but continue
|
||||
logger.warning(
|
||||
f"[{context_name}] Error processing message #{msg_count}: {msg_error}"
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Message processing error: {msg_error}",
|
||||
flush=True,
|
||||
)
|
||||
# Continue processing subsequent messages
|
||||
|
||||
except Exception as e:
|
||||
# Log stream-level errors
|
||||
stream_error = str(e)
|
||||
logger.error(f"[{context_name}] SDK stream processing failed: {e}")
|
||||
print(f"[{context_name}] ERROR: Stream processing failed: {e}", flush=True)
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(f"[{context_name}] Session ended. Total messages: {msg_count}", flush=True)
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
"msg_count": msg_count,
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
}
|
||||
@@ -6,11 +6,18 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Use direct file import to avoid package import issues
|
||||
_github_dir = Path(__file__).parent
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
|
||||
from bot_detection import BotDetectionState, BotDetector
|
||||
|
||||
|
||||
@@ -125,13 +132,15 @@ class TestBotDetection:
|
||||
|
||||
def test_get_last_commit_sha(self, mock_bot_detector):
|
||||
"""Test extracting last commit SHA."""
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit
|
||||
commits = [
|
||||
{"oid": "abc123"},
|
||||
{"oid": "def456"},
|
||||
{"oid": "abc123"}, # Oldest commit
|
||||
{"oid": "def456"}, # Latest commit
|
||||
]
|
||||
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits)
|
||||
assert sha == "abc123"
|
||||
assert sha == "def456" # Should return the LAST (latest) commit
|
||||
|
||||
# Test with sha field instead of oid
|
||||
commits_with_sha = [{"sha": "xyz789"}]
|
||||
@@ -143,13 +152,16 @@ class TestBotDetection:
|
||||
|
||||
|
||||
class TestCoolingOff:
|
||||
"""Test cooling off period."""
|
||||
"""Test cooling off period.
|
||||
|
||||
Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
|
||||
"""
|
||||
|
||||
def test_within_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR within cooling off period."""
|
||||
# Set last review to 5 minutes ago
|
||||
five_min_ago = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
@@ -158,9 +170,9 @@ class TestCoolingOff:
|
||||
|
||||
def test_outside_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR outside cooling off period."""
|
||||
# Set last review to 15 minutes ago
|
||||
fifteen_min_ago = datetime.now() - timedelta(minutes=15)
|
||||
mock_bot_detector.state.last_review_times["123"] = fifteen_min_ago.isoformat()
|
||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
@@ -229,11 +241,16 @@ class TestShouldSkipReview:
|
||||
assert "bot user" in reason
|
||||
|
||||
def test_skip_bot_commit(self, mock_bot_detector):
|
||||
"""Test skipping PR with bot commit."""
|
||||
"""Test skipping PR with bot commit as the latest commit."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit - which is the bot commit
|
||||
commits = [
|
||||
{"author": {"login": "test-bot"}, "oid": "abc123"}, # Latest is bot
|
||||
{"author": {"login": "alice"}, "oid": "def456"},
|
||||
{"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
|
||||
{
|
||||
"author": {"login": "test-bot"},
|
||||
"oid": "def456",
|
||||
}, # Latest commit (by bot)
|
||||
]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
@@ -247,9 +264,9 @@ class TestShouldSkipReview:
|
||||
|
||||
def test_skip_cooling_off(self, mock_bot_detector):
|
||||
"""Test skipping during cooling off period."""
|
||||
# Set last review to 5 minutes ago
|
||||
five_min_ago = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
@@ -349,7 +366,7 @@ class TestStateManagement:
|
||||
assert stats["review_own_prs"] is False
|
||||
assert stats["total_prs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 3
|
||||
assert stats["cooling_off_minutes"] == 10
|
||||
assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
Generated
+91
-28
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -50,6 +51,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.2.1",
|
||||
@@ -66,6 +68,7 @@
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
@@ -198,7 +201,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -583,7 +585,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -627,7 +628,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -667,7 +667,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -1074,6 +1073,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1095,6 +1095,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2730,6 +2731,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -4224,7 +4280,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -4411,7 +4468,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4422,7 +4478,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4437,6 +4492,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -4514,7 +4576,6 @@
|
||||
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.1",
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
@@ -4927,7 +4988,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4988,7 +5048,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -5161,6 +5220,7 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -5555,7 +5615,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6226,7 +6285,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -6594,7 +6654,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -6652,7 +6711,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -6728,7 +6788,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
@@ -6866,6 +6925,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -6886,6 +6946,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -6901,6 +6962,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -6911,6 +6973,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -7280,7 +7343,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8526,7 +8588,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -9317,7 +9378,6 @@
|
||||
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -10249,6 +10309,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -12439,7 +12500,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12537,7 +12597,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -12574,6 +12633,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -12591,6 +12651,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -12611,6 +12672,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -12626,6 +12688,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -12638,7 +12701,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "2.0.1",
|
||||
@@ -12742,7 +12806,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12752,7 +12815,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -14072,8 +14134,7 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -14130,6 +14191,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -14156,6 +14218,7 @@
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -14177,6 +14240,7 @@
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -14190,6 +14254,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -14204,6 +14269,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -14520,7 +14586,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14870,7 +14935,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -15912,7 +15976,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -88,6 +89,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.2.1",
|
||||
@@ -104,6 +106,7 @@
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
|
||||
@@ -66,6 +66,7 @@ const COMMON_BIN_PATHS: Record<string, string[]> = {
|
||||
'/usr/local/bin', // Intel Homebrew / system
|
||||
'/opt/homebrew/sbin', // Apple Silicon Homebrew sbin
|
||||
'/usr/local/sbin', // Intel Homebrew sbin
|
||||
'~/.local/bin', // User-local binaries (Claude CLI)
|
||||
],
|
||||
linux: [
|
||||
'/usr/local/bin',
|
||||
|
||||
@@ -61,6 +61,13 @@ export function registerAgenteventsHandlers(
|
||||
agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
// Send final plan state to renderer BEFORE unwatching
|
||||
// This ensures the renderer has the final subtask data (fixes 0/0 subtask bug)
|
||||
const finalPlan = fileWatcher.getCurrentPlan(taskId);
|
||||
if (finalPlan) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan);
|
||||
}
|
||||
|
||||
fileWatcher.unwatch(taskId);
|
||||
|
||||
if (processType === 'spec-creation') {
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Claude Code CLI Handlers
|
||||
*
|
||||
* IPC handlers for Claude Code CLI version checking and installation.
|
||||
* Provides functionality to:
|
||||
* - Check installed vs latest version
|
||||
* - Open terminal with installation command
|
||||
*/
|
||||
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS } from '../../shared/constants/ipc';
|
||||
import type { IPCResult } from '../../shared/types';
|
||||
import type { ClaudeCodeVersionInfo } from '../../shared/types/cli';
|
||||
import { getToolInfo } from '../cli-tool-manager';
|
||||
import { readSettingsFile } from '../settings-utils';
|
||||
import semver from 'semver';
|
||||
|
||||
// Cache for latest version (avoid hammering npm registry)
|
||||
let cachedLatestVersion: { version: string; timestamp: number } | null = null;
|
||||
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* Fetch the latest version of Claude Code from npm registry
|
||||
*/
|
||||
async function fetchLatestVersion(): Promise<string> {
|
||||
// Check cache first
|
||||
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
return cachedLatestVersion.version;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://registry.npmjs.org/@anthropic-ai/claude-code/latest', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const version = data.version;
|
||||
|
||||
if (!version || typeof version !== 'string') {
|
||||
throw new Error('Invalid version format from npm registry');
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedLatestVersion = { version, timestamp: Date.now() };
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.error('[Claude Code] Failed to fetch latest version:', error);
|
||||
// Return cached version if available, even if expired
|
||||
if (cachedLatestVersion) {
|
||||
return cachedLatestVersion.version;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific install command for Claude Code
|
||||
* @param isUpdate - If true, Claude is already installed and we just need to update
|
||||
*/
|
||||
function getInstallCommand(isUpdate: boolean): string {
|
||||
if (process.platform === 'win32') {
|
||||
if (isUpdate) {
|
||||
// Update: kill running Claude processes first, then update with --force
|
||||
return 'taskkill /IM claude.exe /F 2>nul; claude install --force latest';
|
||||
}
|
||||
return 'irm https://claude.ai/install.ps1 | iex';
|
||||
} else {
|
||||
if (isUpdate) {
|
||||
// Update: kill running Claude processes first, then update with --force
|
||||
// pkill sends SIGTERM to gracefully stop Claude processes
|
||||
return 'pkill -x claude 2>/dev/null; sleep 1; claude install --force latest';
|
||||
}
|
||||
// Fresh install: use the full install script
|
||||
return 'curl -fsSL https://claude.ai/install.sh | bash -s -- latest';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape single quotes in a string for use in AppleScript
|
||||
*/
|
||||
export function escapeAppleScriptString(str: string): string {
|
||||
return str.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a terminal with the given command
|
||||
* Uses the user's preferred terminal from settings
|
||||
* Supports macOS, Windows, and Linux terminals
|
||||
*/
|
||||
export async function openTerminalWithCommand(command: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const settings = readSettingsFile();
|
||||
const preferredTerminal = settings?.preferredTerminal as string | undefined;
|
||||
|
||||
console.log('[Claude Code] Platform:', platform);
|
||||
console.log('[Claude Code] Preferred terminal:', preferredTerminal);
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use AppleScript to open terminal with command
|
||||
const escapedCommand = escapeAppleScriptString(command);
|
||||
let script: string;
|
||||
|
||||
// Map SupportedTerminal values to terminal handling
|
||||
// Values come from settings.preferredTerminal (SupportedTerminal type)
|
||||
const terminalId = preferredTerminal?.toLowerCase() || 'terminal';
|
||||
|
||||
console.log('[Claude Code] Using terminal:', terminalId);
|
||||
|
||||
if (terminalId === 'iterm2') {
|
||||
// iTerm2
|
||||
script = `
|
||||
tell application "iTerm"
|
||||
activate
|
||||
create window with default profile
|
||||
tell current session of current window
|
||||
write text "${escapedCommand}"
|
||||
end tell
|
||||
end tell
|
||||
`;
|
||||
} else if (terminalId === 'warp') {
|
||||
// Warp - open and send command
|
||||
script = `
|
||||
tell application "Warp"
|
||||
activate
|
||||
end tell
|
||||
delay 0.5
|
||||
tell application "System Events"
|
||||
keystroke "${escapedCommand}"
|
||||
keystroke return
|
||||
end tell
|
||||
`;
|
||||
} else if (terminalId === 'kitty') {
|
||||
// Kitty - use command line
|
||||
spawn('kitty', ['--', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'alacritty') {
|
||||
// Alacritty - use command line
|
||||
spawn('open', ['-a', 'Alacritty', '--args', '-e', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'wezterm') {
|
||||
// WezTerm - use command line
|
||||
spawn('wezterm', ['start', '--', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'ghostty') {
|
||||
// Ghostty
|
||||
script = `
|
||||
tell application "Ghostty"
|
||||
activate
|
||||
end tell
|
||||
delay 0.3
|
||||
tell application "System Events"
|
||||
keystroke "${escapedCommand}"
|
||||
keystroke return
|
||||
end tell
|
||||
`;
|
||||
} else if (terminalId === 'hyper') {
|
||||
// Hyper
|
||||
script = `
|
||||
tell application "Hyper"
|
||||
activate
|
||||
end tell
|
||||
delay 0.3
|
||||
tell application "System Events"
|
||||
keystroke "${escapedCommand}"
|
||||
keystroke return
|
||||
end tell
|
||||
`;
|
||||
} else if (terminalId === 'tabby') {
|
||||
// Tabby (formerly Terminus)
|
||||
script = `
|
||||
tell application "Tabby"
|
||||
activate
|
||||
end tell
|
||||
delay 0.3
|
||||
tell application "System Events"
|
||||
keystroke "${escapedCommand}"
|
||||
keystroke return
|
||||
end tell
|
||||
`;
|
||||
} else {
|
||||
// Default: Terminal.app (handles 'terminal', 'system', or any unknown value)
|
||||
// IMPORTANT: do script FIRST, then activate - this prevents opening a blank default window
|
||||
// when Terminal.app isn't already running
|
||||
script = `
|
||||
tell application "Terminal"
|
||||
do script "${escapedCommand}"
|
||||
activate
|
||||
end tell
|
||||
`;
|
||||
}
|
||||
|
||||
console.log('[Claude Code] Running AppleScript...');
|
||||
execFileSync('osascript', ['-e', script], { stdio: 'pipe' });
|
||||
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use appropriate terminal
|
||||
// Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', 'gitbash'
|
||||
const terminalId = preferredTerminal?.toLowerCase() || 'powershell';
|
||||
|
||||
console.log('[Claude Code] Using terminal:', terminalId);
|
||||
|
||||
if (terminalId === 'windowsterminal') {
|
||||
// Windows Terminal
|
||||
spawn('wt.exe', ['powershell.exe', '-NoExit', '-Command', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'cmd') {
|
||||
// Command Prompt
|
||||
spawn('cmd.exe', ['/K', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'conemu') {
|
||||
// ConEmu
|
||||
spawn('ConEmu64.exe', ['-run', 'powershell.exe', '-NoExit', '-Command', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'cmder') {
|
||||
// Cmder (ConEmu-based)
|
||||
spawn('cmder.exe', ['/TASK', 'powershell', '/CMD', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'gitbash') {
|
||||
// Git Bash
|
||||
spawn('C:\\Program Files\\Git\\git-bash.exe', ['-c', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'hyper') {
|
||||
// Hyper
|
||||
spawn('hyper.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref();
|
||||
// Note: Hyper doesn't support direct command execution, user needs to paste
|
||||
} else if (terminalId === 'tabby') {
|
||||
// Tabby
|
||||
spawn('tabby.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref();
|
||||
} else if (terminalId === 'alacritty') {
|
||||
// Alacritty on Windows
|
||||
spawn('alacritty.exe', ['-e', 'powershell.exe', '-NoExit', '-Command', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else if (terminalId === 'wezterm') {
|
||||
// WezTerm on Windows
|
||||
spawn('wezterm.exe', ['start', '--', 'powershell.exe', '-NoExit', '-Command', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
} else {
|
||||
// Default: PowerShell (handles 'powershell', 'system', or any unknown value)
|
||||
spawn('powershell.exe', ['-NoExit', '-Command', command], {
|
||||
detached: true, stdio: 'ignore', shell: false,
|
||||
}).unref();
|
||||
}
|
||||
} else {
|
||||
// Linux: Use preferred terminal or try common emulators
|
||||
// Values match SupportedTerminal type: 'gnometerminal', 'konsole', 'xfce4terminal', 'tilix', etc.
|
||||
const terminalId = preferredTerminal?.toLowerCase() || '';
|
||||
|
||||
console.log('[Claude Code] Using terminal:', terminalId || 'auto-detect');
|
||||
|
||||
// Command to run (keep terminal open after execution)
|
||||
const bashCommand = `${command}; exec bash`;
|
||||
|
||||
// Try to use preferred terminal if specified
|
||||
if (terminalId === 'gnometerminal') {
|
||||
spawn('gnome-terminal', ['--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'konsole') {
|
||||
spawn('konsole', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'xfce4terminal') {
|
||||
spawn('xfce4-terminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'lxterminal') {
|
||||
spawn('lxterminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'mate-terminal') {
|
||||
spawn('mate-terminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'tilix') {
|
||||
spawn('tilix', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'terminator') {
|
||||
spawn('terminator', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'guake') {
|
||||
spawn('guake', ['-e', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'yakuake') {
|
||||
spawn('yakuake', ['-e', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'kitty') {
|
||||
spawn('kitty', ['--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'alacritty') {
|
||||
spawn('alacritty', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'wezterm') {
|
||||
spawn('wezterm', ['start', '--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'hyper') {
|
||||
spawn('hyper', [], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'tabby') {
|
||||
spawn('tabby', [], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'xterm') {
|
||||
spawn('xterm', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'urxvt') {
|
||||
spawn('urxvt', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'st') {
|
||||
spawn('st', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
} else if (terminalId === 'foot') {
|
||||
spawn('foot', ['bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-detect (for 'system' or no preference): try common terminal emulators in order
|
||||
const terminals: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: 'gnome-terminal', args: ['--', 'bash', '-c', bashCommand] },
|
||||
{ cmd: 'konsole', args: ['-e', 'bash', '-c', bashCommand] },
|
||||
{ cmd: 'xfce4-terminal', args: ['-e', `bash -c "${bashCommand}"`] },
|
||||
{ cmd: 'tilix', args: ['-e', 'bash', '-c', bashCommand] },
|
||||
{ cmd: 'terminator', args: ['-e', `bash -c "${bashCommand}"`] },
|
||||
{ cmd: 'kitty', args: ['--', 'bash', '-c', bashCommand] },
|
||||
{ cmd: 'alacritty', args: ['-e', 'bash', '-c', bashCommand] },
|
||||
{ cmd: 'xterm', args: ['-e', 'bash', '-c', bashCommand] },
|
||||
];
|
||||
|
||||
let opened = false;
|
||||
for (const { cmd, args } of terminals) {
|
||||
try {
|
||||
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
||||
opened = true;
|
||||
console.log('[Claude Code] Opened terminal:', cmd);
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
throw new Error('No supported terminal emulator found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Claude Code IPC handlers
|
||||
*/
|
||||
export function registerClaudeCodeHandlers(): void {
|
||||
// Check Claude Code version
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
||||
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
try {
|
||||
console.log('[Claude Code] Checking version...');
|
||||
|
||||
// Get installed version via cli-tool-manager
|
||||
let detectionResult;
|
||||
try {
|
||||
detectionResult = getToolInfo('claude');
|
||||
console.log('[Claude Code] Detection result:', JSON.stringify(detectionResult, null, 2));
|
||||
} catch (detectionError) {
|
||||
console.error('[Claude Code] Detection error:', detectionError);
|
||||
throw new Error(`Detection failed: ${detectionError instanceof Error ? detectionError.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const installed = detectionResult.found ? detectionResult.version || null : null;
|
||||
console.log('[Claude Code] Installed version:', installed);
|
||||
|
||||
// Fetch latest version from npm
|
||||
let latest: string;
|
||||
try {
|
||||
console.log('[Claude Code] Fetching latest version from npm...');
|
||||
latest = await fetchLatestVersion();
|
||||
console.log('[Claude Code] Latest version:', latest);
|
||||
} catch (error) {
|
||||
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
||||
// If we can't fetch latest, still return installed info
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
installed,
|
||||
latest: 'unknown',
|
||||
isOutdated: false,
|
||||
path: detectionResult.path,
|
||||
detectionResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
let isOutdated = false;
|
||||
if (installed && latest !== 'unknown') {
|
||||
try {
|
||||
// Clean version strings (remove 'v' prefix if present)
|
||||
const cleanInstalled = installed.replace(/^v/, '');
|
||||
const cleanLatest = latest.replace(/^v/, '');
|
||||
isOutdated = semver.lt(cleanInstalled, cleanLatest);
|
||||
} catch {
|
||||
// If semver comparison fails, assume not outdated
|
||||
isOutdated = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Claude Code] Check complete:', { installed, latest, isOutdated });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
installed,
|
||||
latest,
|
||||
isOutdated,
|
||||
path: detectionResult.path,
|
||||
detectionResult,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[Claude Code] Check failed:', errorMsg, error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to check Claude Code version: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Install Claude Code (open terminal with install command)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_CODE_INSTALL,
|
||||
async (): Promise<IPCResult<{ command: string }>> => {
|
||||
try {
|
||||
// Check if Claude is already installed to determine if this is an update
|
||||
let isUpdate = false;
|
||||
try {
|
||||
const detectionResult = getToolInfo('claude');
|
||||
isUpdate = detectionResult.found && !!detectionResult.version;
|
||||
console.log('[Claude Code] Is update:', isUpdate, 'detected version:', detectionResult.version);
|
||||
} catch {
|
||||
// Detection failed, assume fresh install
|
||||
isUpdate = false;
|
||||
}
|
||||
|
||||
const command = getInstallCommand(isUpdate);
|
||||
console.log('[Claude Code] Install command:', command);
|
||||
console.log('[Claude Code] Opening terminal...');
|
||||
await openTerminalWithCommand(command);
|
||||
console.log('[Claude Code] Terminal opened successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { command },
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[Claude Code] Install failed:', errorMsg, error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to open terminal for installation: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.warn('[IPC] Claude Code handlers registered');
|
||||
}
|
||||
@@ -141,6 +141,52 @@ export function registerEnvHandlers(
|
||||
existingVars['ENABLE_FANCY_UI'] = config.enableFancyUi ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// MCP Server Configuration
|
||||
if (config.mcpServers) {
|
||||
if (config.mcpServers.context7Enabled !== undefined) {
|
||||
existingVars['CONTEXT7_ENABLED'] = config.mcpServers.context7Enabled ? 'true' : 'false';
|
||||
}
|
||||
if (config.mcpServers.linearMcpEnabled !== undefined) {
|
||||
existingVars['LINEAR_MCP_ENABLED'] = config.mcpServers.linearMcpEnabled ? 'true' : 'false';
|
||||
}
|
||||
if (config.mcpServers.electronEnabled !== undefined) {
|
||||
existingVars['ELECTRON_MCP_ENABLED'] = config.mcpServers.electronEnabled ? 'true' : 'false';
|
||||
}
|
||||
if (config.mcpServers.puppeteerEnabled !== undefined) {
|
||||
existingVars['PUPPETEER_MCP_ENABLED'] = config.mcpServers.puppeteerEnabled ? 'true' : 'false';
|
||||
}
|
||||
// Note: graphitiEnabled is already handled via GRAPHITI_ENABLED above
|
||||
}
|
||||
|
||||
// Per-agent MCP overrides (add/remove MCPs from specific agents)
|
||||
if (config.agentMcpOverrides) {
|
||||
// First, clear any existing AGENT_MCP_* entries
|
||||
Object.keys(existingVars).forEach(key => {
|
||||
if (key.startsWith('AGENT_MCP_')) {
|
||||
delete existingVars[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Add new overrides
|
||||
Object.entries(config.agentMcpOverrides).forEach(([agentId, override]) => {
|
||||
if (override.add && override.add.length > 0) {
|
||||
existingVars[`AGENT_MCP_${agentId}_ADD`] = override.add.join(',');
|
||||
}
|
||||
if (override.remove && override.remove.length > 0) {
|
||||
existingVars[`AGENT_MCP_${agentId}_REMOVE`] = override.remove.join(',');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Custom MCP servers (user-defined)
|
||||
if (config.customMcpServers !== undefined) {
|
||||
if (config.customMcpServers.length > 0) {
|
||||
existingVars['CUSTOM_MCP_SERVERS'] = JSON.stringify(config.customMcpServers);
|
||||
} else {
|
||||
delete existingVars['CUSTOM_MCP_SERVERS'];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate content with sections
|
||||
const content = `# Auto Claude Framework Environment Variables
|
||||
# Managed by Auto Claude UI
|
||||
@@ -187,6 +233,36 @@ ${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANC
|
||||
# =============================================================================
|
||||
${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVars['ENABLE_FANCY_UI']}` : '# ENABLE_FANCY_UI=true'}
|
||||
|
||||
# =============================================================================
|
||||
# MCP SERVER CONFIGURATION (per-project overrides)
|
||||
# =============================================================================
|
||||
# Context7 documentation lookup (default: enabled)
|
||||
${existingVars['CONTEXT7_ENABLED'] !== undefined ? `CONTEXT7_ENABLED=${existingVars['CONTEXT7_ENABLED']}` : '# CONTEXT7_ENABLED=true'}
|
||||
# Linear MCP integration (default: follows LINEAR_API_KEY)
|
||||
${existingVars['LINEAR_MCP_ENABLED'] !== undefined ? `LINEAR_MCP_ENABLED=${existingVars['LINEAR_MCP_ENABLED']}` : '# LINEAR_MCP_ENABLED=true'}
|
||||
# Electron desktop automation - QA agents only (default: disabled)
|
||||
${existingVars['ELECTRON_MCP_ENABLED'] !== undefined ? `ELECTRON_MCP_ENABLED=${existingVars['ELECTRON_MCP_ENABLED']}` : '# ELECTRON_MCP_ENABLED=false'}
|
||||
# Puppeteer browser automation - QA agents only (default: disabled)
|
||||
${existingVars['PUPPETEER_MCP_ENABLED'] !== undefined ? `PUPPETEER_MCP_ENABLED=${existingVars['PUPPETEER_MCP_ENABLED']}` : '# PUPPETEER_MCP_ENABLED=false'}
|
||||
|
||||
# =============================================================================
|
||||
# PER-AGENT MCP OVERRIDES
|
||||
# Add or remove MCP servers for specific agents
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# Format: AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
# =============================================================================
|
||||
${Object.entries(existingVars)
|
||||
.filter(([key]) => key.startsWith('AGENT_MCP_'))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n') || '# No per-agent overrides configured'}
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOM MCP SERVERS
|
||||
# User-defined MCP servers (command-based or HTTP-based)
|
||||
# JSON format: [{"id":"...","name":"...","type":"command|http",...}]
|
||||
# =============================================================================
|
||||
${existingVars['CUSTOM_MCP_SERVERS'] ? `CUSTOM_MCP_SERVERS=${existingVars['CUSTOM_MCP_SERVERS']}` : '# CUSTOM_MCP_SERVERS=[]'}
|
||||
|
||||
# =============================================================================
|
||||
# MEMORY INTEGRATION
|
||||
# Embedding providers: OpenAI, Google AI, Azure OpenAI, Ollama, Voyage
|
||||
@@ -389,6 +465,44 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
};
|
||||
}
|
||||
|
||||
// MCP Server Configuration (per-project overrides)
|
||||
// Default: context7=true, linear=true (if API key set), electron/puppeteer=false
|
||||
config.mcpServers = {
|
||||
context7Enabled: vars['CONTEXT7_ENABLED']?.toLowerCase() !== 'false', // default true
|
||||
graphitiEnabled: config.graphitiEnabled, // follows GRAPHITI_ENABLED
|
||||
linearMcpEnabled: vars['LINEAR_MCP_ENABLED']?.toLowerCase() !== 'false', // default true
|
||||
electronEnabled: vars['ELECTRON_MCP_ENABLED']?.toLowerCase() === 'true', // default false
|
||||
puppeteerEnabled: vars['PUPPETEER_MCP_ENABLED']?.toLowerCase() === 'true', // default false
|
||||
};
|
||||
|
||||
// Parse per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
|
||||
const agentMcpOverrides: Record<string, { add?: string[]; remove?: string[] }> = {};
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (key.startsWith('AGENT_MCP_') && key.endsWith('_ADD')) {
|
||||
const agentId = key.replace('AGENT_MCP_', '').replace('_ADD', '');
|
||||
if (!agentMcpOverrides[agentId]) agentMcpOverrides[agentId] = {};
|
||||
agentMcpOverrides[agentId].add = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else if (key.startsWith('AGENT_MCP_') && key.endsWith('_REMOVE')) {
|
||||
const agentId = key.replace('AGENT_MCP_', '').replace('_REMOVE', '');
|
||||
if (!agentMcpOverrides[agentId]) agentMcpOverrides[agentId] = {};
|
||||
agentMcpOverrides[agentId].remove = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(agentMcpOverrides).length > 0) {
|
||||
config.agentMcpOverrides = agentMcpOverrides;
|
||||
}
|
||||
|
||||
// Parse custom MCP servers (user-defined)
|
||||
if (vars['CUSTOM_MCP_SERVERS']) {
|
||||
try {
|
||||
config.customMcpServers = JSON.parse(vars['CUSTOM_MCP_SERVERS']);
|
||||
} catch {
|
||||
// Invalid JSON, ignore
|
||||
config.customMcpServers = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: config };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -120,6 +120,8 @@ export interface NewCommitsCheck {
|
||||
newCommitCount: number;
|
||||
lastReviewedCommit?: string;
|
||||
currentHeadCommit?: string;
|
||||
/** Whether new commits happened AFTER findings were posted (for "Ready for Follow-up" status) */
|
||||
hasCommitsAfterPosting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +167,342 @@ function getGitHubDir(project: Project): string {
|
||||
return path.join(project.path, '.auto-claude', 'github');
|
||||
}
|
||||
|
||||
/**
|
||||
* PR log phase type
|
||||
*/
|
||||
type PRLogPhase = 'context' | 'analysis' | 'synthesis';
|
||||
|
||||
/**
|
||||
* PR log entry type
|
||||
*/
|
||||
type PRLogEntryType = 'text' | 'tool_start' | 'tool_end' | 'phase_start' | 'phase_end' | 'error' | 'success' | 'info';
|
||||
|
||||
/**
|
||||
* Single PR log entry
|
||||
*/
|
||||
interface PRLogEntry {
|
||||
timestamp: string;
|
||||
type: PRLogEntryType;
|
||||
content: string;
|
||||
phase: PRLogPhase;
|
||||
source?: string;
|
||||
detail?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase log with entries
|
||||
*/
|
||||
interface PRPhaseLog {
|
||||
phase: PRLogPhase;
|
||||
status: 'pending' | 'active' | 'completed' | 'failed';
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
entries: PRLogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete PR logs structure
|
||||
*/
|
||||
interface PRLogs {
|
||||
pr_number: number;
|
||||
repo: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_followup: boolean;
|
||||
phases: {
|
||||
context: PRPhaseLog;
|
||||
analysis: PRPhaseLog;
|
||||
synthesis: PRPhaseLog;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a log line and extract source and content
|
||||
* Returns null if line is not a log line
|
||||
*/
|
||||
function parseLogLine(line: string): { source: string; content: string; isError: boolean } | null {
|
||||
// Match patterns like [Context], [AI], [Orchestrator], [Followup], [DEBUG ...], [ParallelFollowup], [BotDetector], [ParallelOrchestrator]
|
||||
const patterns = [
|
||||
/^\[Context\]\s*(.*)$/,
|
||||
/^\[AI\]\s*(.*)$/,
|
||||
/^\[Orchestrator\]\s*(.*)$/,
|
||||
/^\[Followup\]\s*(.*)$/,
|
||||
/^\[ParallelFollowup\]\s*(.*)$/,
|
||||
/^\[ParallelOrchestrator\]\s*(.*)$/,
|
||||
/^\[BotDetector\]\s*(.*)$/,
|
||||
/^\[PR Review Engine\]\s*(.*)$/,
|
||||
/^\[DEBUG\s+(\w+)\]\s*(.*)$/,
|
||||
/^\[ERROR\s+(\w+)\]\s*(.*)$/,
|
||||
];
|
||||
|
||||
// Check for specialist agent logs first (Agent:agent-name format)
|
||||
const agentMatch = line.match(/^\[Agent:([\w-]+)\]\s*(.*)$/);
|
||||
if (agentMatch) {
|
||||
return {
|
||||
source: `Agent:${agentMatch[1]}`,
|
||||
content: agentMatch[2],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
const isDebugOrError = pattern.source.includes('DEBUG') || pattern.source.includes('ERROR');
|
||||
if (isDebugOrError && match.length >= 3) {
|
||||
// Skip debug messages that only show message types (not useful)
|
||||
if (match[2].match(/^Message #\d+: \w+Message/)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
source: match[1],
|
||||
content: match[2],
|
||||
isError: pattern.source.includes('ERROR'),
|
||||
};
|
||||
}
|
||||
const source = line.match(/^\[(\w+(?:\s+\w+)*)\]/)?.[1] || 'Unknown';
|
||||
return {
|
||||
source,
|
||||
content: match[1] || line,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for PR progress messages [PR #XXX] [YY%] message
|
||||
const prProgressMatch = line.match(/^\[PR #\d+\]\s*\[\s*(\d+)%\]\s*(.*)$/);
|
||||
if (prProgressMatch) {
|
||||
return {
|
||||
source: 'Progress',
|
||||
content: `[${prProgressMatch[1]}%] ${prProgressMatch[2]}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for progress messages [XX%]
|
||||
const progressMatch = line.match(/^\[(\d+)%\]\s*(.*)$/);
|
||||
if (progressMatch) {
|
||||
return {
|
||||
source: 'Progress',
|
||||
content: `[${progressMatch[1]}%] ${progressMatch[2]}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Match final summary lines (Status:, Summary:, Findings:, etc.)
|
||||
const summaryPatterns = [
|
||||
/^(Status|Summary|Findings|Verdict|Is Follow-up|Resolved|Still Open|New Issues):\s*(.*)$/,
|
||||
/^PR #\d+ (Follow-up )?Review Complete$/,
|
||||
/^={10,}$/,
|
||||
/^-{10,}$/,
|
||||
// Markdown headers (## Summary, ### Resolution Status, etc.)
|
||||
/^#{1,4}\s+.+$/,
|
||||
// Bullet points with content (- ✅ **Resolved**, - **Blocking Issues**, etc.)
|
||||
/^[-*]\s+.+$/,
|
||||
// Indented bullet points for findings ( - [MEDIUM] ..., . [LOW] ...)
|
||||
/^\s+[-.*]\s+\[.+$/,
|
||||
// Lines with bold text at start (**Why NEEDS_REVISION:**, **Recommended Actions:**)
|
||||
/^\*\*.+\*\*:?\s*$/,
|
||||
// Numbered list items (1. Add DANGEROUS_FLAGS...)
|
||||
/^\d+\.\s+.+$/,
|
||||
// File references (File: apps/backend/...)
|
||||
/^\s+File:\s+.+$/,
|
||||
];
|
||||
for (const pattern of summaryPatterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
source: 'Summary',
|
||||
content: line,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the phase from source
|
||||
*/
|
||||
function getPhaseFromSource(source: string): PRLogPhase {
|
||||
const contextSources = ['Context', 'BotDetector'];
|
||||
const analysisSources = ['AI', 'Orchestrator', 'ParallelOrchestrator', 'ParallelFollowup', 'Followup', 'orchestrator'];
|
||||
const synthesisSources = ['PR Review Engine', 'Summary', 'Progress'];
|
||||
|
||||
if (contextSources.includes(source)) return 'context';
|
||||
if (analysisSources.includes(source)) return 'analysis';
|
||||
// Specialist agents (Agent:xxx) are part of analysis phase
|
||||
if (source.startsWith('Agent:')) return 'analysis';
|
||||
if (synthesisSources.includes(source)) return 'synthesis';
|
||||
return 'synthesis'; // Default to synthesis for unknown sources
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty PR logs structure
|
||||
*/
|
||||
function createEmptyPRLogs(prNumber: number, repo: string, isFollowup: boolean): PRLogs {
|
||||
const now = new Date().toISOString();
|
||||
const createEmptyPhase = (phase: PRLogPhase): PRPhaseLog => ({
|
||||
phase,
|
||||
status: 'pending',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
entries: [],
|
||||
});
|
||||
|
||||
return {
|
||||
pr_number: prNumber,
|
||||
repo,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_followup: isFollowup,
|
||||
phases: {
|
||||
context: createEmptyPhase('context'),
|
||||
analysis: createEmptyPhase('analysis'),
|
||||
synthesis: createEmptyPhase('synthesis'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR logs file path
|
||||
*/
|
||||
function getPRLogsPath(project: Project, prNumber: number): string {
|
||||
return path.join(getGitHubDir(project), 'pr', `logs_${prNumber}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load PR logs from disk
|
||||
*/
|
||||
function loadPRLogs(project: Project, prNumber: number): PRLogs | null {
|
||||
const logsPath = getPRLogsPath(project, prNumber);
|
||||
|
||||
try {
|
||||
const rawData = fs.readFileSync(logsPath, 'utf-8');
|
||||
const sanitizedData = sanitizeNetworkData(rawData);
|
||||
return JSON.parse(sanitizedData) as PRLogs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PR logs to disk
|
||||
*/
|
||||
function savePRLogs(project: Project, logs: PRLogs): void {
|
||||
const logsPath = getPRLogsPath(project, logs.pr_number);
|
||||
const prDir = path.dirname(logsPath);
|
||||
|
||||
if (!fs.existsSync(prDir)) {
|
||||
fs.mkdirSync(prDir, { recursive: true });
|
||||
}
|
||||
|
||||
logs.updated_at = new Date().toISOString();
|
||||
fs.writeFileSync(logsPath, JSON.stringify(logs, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry to PR logs
|
||||
*/
|
||||
function addLogEntry(logs: PRLogs, entry: PRLogEntry): void {
|
||||
const phase = logs.phases[entry.phase];
|
||||
|
||||
// Update phase status if needed
|
||||
if (phase.status === 'pending') {
|
||||
phase.status = 'active';
|
||||
phase.started_at = entry.timestamp;
|
||||
}
|
||||
|
||||
phase.entries.push(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR Log Collector - collects logs during review
|
||||
* Saves incrementally to disk so frontend can stream logs in real-time
|
||||
*/
|
||||
class PRLogCollector {
|
||||
private logs: PRLogs;
|
||||
private project: Project;
|
||||
private currentPhase: PRLogPhase = 'context';
|
||||
private entryCount: number = 0;
|
||||
private saveInterval: number = 3; // Save every N entries for real-time streaming
|
||||
|
||||
constructor(project: Project, prNumber: number, repo: string, isFollowup: boolean) {
|
||||
this.project = project;
|
||||
this.logs = createEmptyPRLogs(prNumber, repo, isFollowup);
|
||||
// Save initial empty logs so frontend sees the structure immediately
|
||||
this.save();
|
||||
}
|
||||
|
||||
processLine(line: string): void {
|
||||
const parsed = parseLogLine(line);
|
||||
if (!parsed) return;
|
||||
|
||||
const phase = getPhaseFromSource(parsed.source);
|
||||
|
||||
// Track phase transitions - mark previous phases as complete
|
||||
if (phase !== this.currentPhase) {
|
||||
// When moving to a new phase, mark the previous phase as complete
|
||||
if (this.currentPhase === 'context' && (phase === 'analysis' || phase === 'synthesis')) {
|
||||
this.markPhaseComplete('context', true);
|
||||
}
|
||||
if (this.currentPhase === 'analysis' && phase === 'synthesis') {
|
||||
this.markPhaseComplete('analysis', true);
|
||||
}
|
||||
this.currentPhase = phase;
|
||||
}
|
||||
|
||||
const entry: PRLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: parsed.isError ? 'error' : 'text',
|
||||
content: parsed.content,
|
||||
phase,
|
||||
source: parsed.source,
|
||||
};
|
||||
|
||||
addLogEntry(this.logs, entry);
|
||||
this.entryCount++;
|
||||
|
||||
// Save periodically for real-time streaming (every N entries)
|
||||
if (this.entryCount % this.saveInterval === 0) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
markPhaseComplete(phase: PRLogPhase, success: boolean): void {
|
||||
const phaseLog = this.logs.phases[phase];
|
||||
phaseLog.status = success ? 'completed' : 'failed';
|
||||
phaseLog.completed_at = new Date().toISOString();
|
||||
// Save immediately so frontend sees the status change
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(): void {
|
||||
savePRLogs(this.project, this.logs);
|
||||
}
|
||||
|
||||
finalize(success: boolean): void {
|
||||
// Mark all phases as completed based on success status
|
||||
// For phases with entries: mark based on success
|
||||
// For phases without entries (pending): mark as completed if previous phases completed
|
||||
let previousCompleted = true;
|
||||
for (const phase of ['context', 'analysis', 'synthesis'] as PRLogPhase[]) {
|
||||
const phaseLog = this.logs.phases[phase];
|
||||
if (phaseLog.status === 'active') {
|
||||
this.markPhaseComplete(phase, success);
|
||||
previousCompleted = success;
|
||||
} else if (phaseLog.status === 'pending' && previousCompleted && success) {
|
||||
// If review succeeded, mark pending phases as completed (they just had no logs)
|
||||
phaseLog.status = 'completed';
|
||||
phaseLog.completed_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved PR review result
|
||||
*/
|
||||
@@ -278,10 +616,22 @@ async function runPRReview(
|
||||
|
||||
debugLog('Spawning PR review process', { args, model, thinkingLevel });
|
||||
|
||||
// Create log collector for this review
|
||||
const config = getGitHubConfig(project);
|
||||
const repo = config?.repo || project.name || 'unknown';
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, false);
|
||||
|
||||
// Build environment with project settings
|
||||
const subprocessEnv: Record<string, string> = {};
|
||||
if (project.settings?.useClaudeMd !== false) {
|
||||
subprocessEnv['USE_CLAUDE_MD'] = 'true';
|
||||
}
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: subprocessEnv,
|
||||
onProgress: (percent, message) => {
|
||||
debugLog('Progress update', { percent, message });
|
||||
sendProgress({
|
||||
@@ -291,7 +641,11 @@ async function runPRReview(
|
||||
message,
|
||||
});
|
||||
},
|
||||
onStdout: (line) => debugLog('STDOUT:', line),
|
||||
onStdout: (line) => {
|
||||
debugLog('STDOUT:', line);
|
||||
// Collect log entries
|
||||
logCollector.processLine(line);
|
||||
},
|
||||
onStderr: (line) => debugLog('STDERR:', line),
|
||||
onComplete: () => {
|
||||
// Load the result from disk
|
||||
@@ -314,9 +668,13 @@ async function runPRReview(
|
||||
const result = await promise;
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
throw new Error(result.error ?? 'Review failed');
|
||||
}
|
||||
|
||||
// Finalize logs with success
|
||||
logCollector.finalize(true);
|
||||
return result.data!;
|
||||
} finally {
|
||||
// Clean up the registry when done (success or error)
|
||||
@@ -500,6 +858,16 @@ export function registerPRHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get PR review logs
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_GET_LOGS,
|
||||
async (_, projectId: string, prNumber: number): Promise<PRLogs | null> => {
|
||||
return withProjectOrNull(projectId, async (project) => {
|
||||
return loadPRLogs(project, prNumber);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Run AI review
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.GITHUB_PR_REVIEW,
|
||||
@@ -982,6 +1350,7 @@ export function registerPRHandlers(
|
||||
newCommitCount: 0,
|
||||
lastReviewedCommit: reviewedCommitSha,
|
||||
currentHeadCommit: currentHeadSha,
|
||||
hasCommitsAfterPosting: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -989,13 +1358,36 @@ export function registerPRHandlers(
|
||||
const comparison = (await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/compare/${reviewedCommitSha}...${currentHeadSha}`
|
||||
)) as { ahead_by?: number; total_commits?: number };
|
||||
)) as { ahead_by?: number; total_commits?: number; commits?: Array<{ commit: { committer: { date: string } } }> };
|
||||
|
||||
// Check if findings have been posted and if new commits are after the posting date
|
||||
const postedAt = review.postedAt || (review as any).posted_at;
|
||||
let hasCommitsAfterPosting = true; // Default to true if we can't determine
|
||||
|
||||
if (postedAt && comparison.commits && comparison.commits.length > 0) {
|
||||
const postedAtDate = new Date(postedAt);
|
||||
// Check if any commit is newer than when findings were posted
|
||||
hasCommitsAfterPosting = comparison.commits.some(c => {
|
||||
const commitDate = new Date(c.commit.committer.date);
|
||||
return commitDate > postedAtDate;
|
||||
});
|
||||
debugLog('Comparing commit dates with posted_at', {
|
||||
prNumber,
|
||||
postedAt,
|
||||
latestCommitDate: comparison.commits[comparison.commits.length - 1]?.commit.committer.date,
|
||||
hasCommitsAfterPosting,
|
||||
});
|
||||
} else if (!postedAt) {
|
||||
// If findings haven't been posted yet, any new commits should trigger follow-up
|
||||
hasCommitsAfterPosting = true;
|
||||
}
|
||||
|
||||
return {
|
||||
hasNewCommits: true,
|
||||
newCommitCount: comparison.ahead_by || comparison.total_commits || 1,
|
||||
lastReviewedCommit: reviewedCommitSha,
|
||||
currentHeadCommit: currentHeadSha,
|
||||
hasCommitsAfterPosting,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Error checking new commits', { prNumber, error: error instanceof Error ? error.message : error });
|
||||
@@ -1065,10 +1457,22 @@ export function registerPRHandlers(
|
||||
|
||||
debugLog('Spawning follow-up review process', { args, model, thinkingLevel });
|
||||
|
||||
// Create log collector for this follow-up review
|
||||
const config = getGitHubConfig(project);
|
||||
const repo = config?.repo || project.name || 'unknown';
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, true);
|
||||
|
||||
// Build environment with project settings
|
||||
const followupEnv: Record<string, string> = {};
|
||||
if (project.settings?.useClaudeMd !== false) {
|
||||
followupEnv['USE_CLAUDE_MD'] = 'true';
|
||||
}
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: followupEnv,
|
||||
onProgress: (percent, message) => {
|
||||
debugLog('Progress update', { percent, message });
|
||||
sendProgress({
|
||||
@@ -1078,7 +1482,11 @@ export function registerPRHandlers(
|
||||
message,
|
||||
});
|
||||
},
|
||||
onStdout: (line) => debugLog('STDOUT:', line),
|
||||
onStdout: (line) => {
|
||||
debugLog('STDOUT:', line);
|
||||
// Collect log entries
|
||||
logCollector.processLine(line);
|
||||
},
|
||||
onStderr: (line) => debugLog('STDERR:', line),
|
||||
onComplete: () => {
|
||||
// Load the result from disk
|
||||
@@ -1099,9 +1507,14 @@ export function registerPRHandlers(
|
||||
const result = await promise;
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
throw new Error(result.error ?? 'Follow-up review failed');
|
||||
}
|
||||
|
||||
// Finalize logs with success
|
||||
logCollector.finalize(true);
|
||||
|
||||
debugLog('Follow-up review completed', { prNumber, findingsCount: result.data?.findings.length });
|
||||
sendProgress({
|
||||
phase: 'complete',
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface SubprocessOptions {
|
||||
onComplete?: (stdout: string, stderr: string) => unknown;
|
||||
onError?: (error: string) => void;
|
||||
progressPattern?: RegExp;
|
||||
/** Additional environment variables to pass to the subprocess */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +77,13 @@ export function runPythonSubprocess<T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
// Merge in any additional env vars passed by the caller (e.g., USE_CLAUDE_MD)
|
||||
if (options.env) {
|
||||
for (const [key, value] of Object.entries(options.env)) {
|
||||
filteredEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Python command to handle paths with spaces (e.g., ~/Library/Application Support/...)
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(options.pythonPath);
|
||||
const child = spawn(pythonCommand, [...pythonBaseArgs, ...options.args], {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
import { getAugmentedEnv, findExecutable } from '../../env-utils';
|
||||
import { openTerminalWithCommand } from '../claude-code-handlers';
|
||||
import type { GitLabAuthStartResult } from './types';
|
||||
|
||||
const DEFAULT_GITLAB_URL = 'https://gitlab.com';
|
||||
@@ -118,6 +119,51 @@ export function registerCheckGlabCli(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install glab CLI by opening a terminal with the appropriate install command
|
||||
* Uses the user's preferred terminal from settings
|
||||
*/
|
||||
export function registerInstallGlabCli(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITLAB_INSTALL_CLI,
|
||||
async (): Promise<IPCResult<{ command: string }>> => {
|
||||
debugLog('installGitLabCli handler called');
|
||||
try {
|
||||
const platform = process.platform;
|
||||
let command: string;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use Homebrew
|
||||
command = 'brew install glab';
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use winget
|
||||
command = 'winget install --id GitLab.glab';
|
||||
} else {
|
||||
// Linux: Try snap first, then homebrew
|
||||
command = 'sudo snap install glab || brew install glab';
|
||||
}
|
||||
|
||||
debugLog('Install command:', command);
|
||||
debugLog('Opening terminal...');
|
||||
await openTerminalWithCommand(command);
|
||||
debugLog('Terminal opened successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { command }
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
debugLog('Install failed:', errorMsg);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to open terminal for installation: ${errorMsg}`
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated with glab CLI
|
||||
*/
|
||||
@@ -717,6 +763,7 @@ export function registerListGitLabGroups(): void {
|
||||
export function registerGitlabOAuthHandlers(): void {
|
||||
debugLog('Registering GitLab OAuth handlers');
|
||||
registerCheckGlabCli();
|
||||
registerInstallGlabCli();
|
||||
registerCheckGlabAuth();
|
||||
registerStartGlabAuth();
|
||||
registerGetGlabToken();
|
||||
|
||||
@@ -30,6 +30,8 @@ import { registerInsightsHandlers } from './insights-handlers';
|
||||
import { registerMemoryHandlers } from './memory-handlers';
|
||||
import { registerAppUpdateHandlers } from './app-update-handlers';
|
||||
import { registerDebugHandlers } from './debug-handlers';
|
||||
import { registerClaudeCodeHandlers } from './claude-code-handlers';
|
||||
import { registerMcpHandlers } from './mcp-handlers';
|
||||
import { notificationService } from '../notification-service';
|
||||
|
||||
/**
|
||||
@@ -106,6 +108,12 @@ export function setupIpcHandlers(
|
||||
// Debug handlers (logs, debug info, etc.)
|
||||
registerDebugHandlers();
|
||||
|
||||
// Claude Code CLI handlers (version checking, installation)
|
||||
registerClaudeCodeHandlers();
|
||||
|
||||
// MCP server health check handlers
|
||||
registerMcpHandlers();
|
||||
|
||||
console.warn('[IPC] All handler modules registered successfully');
|
||||
}
|
||||
|
||||
@@ -129,5 +137,7 @@ export {
|
||||
registerInsightsHandlers,
|
||||
registerMemoryHandlers,
|
||||
registerAppUpdateHandlers,
|
||||
registerDebugHandlers
|
||||
registerDebugHandlers,
|
||||
registerClaudeCodeHandlers,
|
||||
registerMcpHandlers
|
||||
};
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* MCP Server Health Check Handlers
|
||||
*
|
||||
* Handles IPC requests for checking MCP server health and connectivity.
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants/ipc';
|
||||
import type { CustomMcpServer, McpHealthCheckResult, McpHealthStatus, McpTestConnectionResult } from '../../shared/types/project';
|
||||
import { spawn } from 'child_process';
|
||||
import { appLog } from '../app-logger';
|
||||
|
||||
/**
|
||||
* Defense-in-depth: Frontend-side command validation
|
||||
* Mirrors the backend SAFE_COMMANDS allowlist to prevent arbitrary command execution
|
||||
* even if malicious configs somehow bypass backend validation
|
||||
*/
|
||||
const SAFE_COMMANDS = new Set(['npx', 'npm', 'node', 'python', 'python3', 'uv', 'uvx']);
|
||||
|
||||
/**
|
||||
* Defense-in-depth: Dangerous interpreter flags that allow code execution
|
||||
* Mirrors backend DANGEROUS_FLAGS to prevent args-based code injection
|
||||
*/
|
||||
const DANGEROUS_FLAGS = new Set([
|
||||
'--eval', '-e', '-c', '--exec',
|
||||
'-m', '-p', '--print',
|
||||
'--input-type=module', '--experimental-loader',
|
||||
'--require', '-r'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate that a command is in the safe allowlist
|
||||
*/
|
||||
function isCommandSafe(command: string | undefined): boolean {
|
||||
if (!command) return false;
|
||||
// Reject commands with paths (defense against path traversal)
|
||||
if (command.includes('/') || command.includes('\\')) return false;
|
||||
return SAFE_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that args don't contain dangerous interpreter flags
|
||||
*/
|
||||
function areArgsSafe(args: string[] | undefined): boolean {
|
||||
if (!args || args.length === 0) return true;
|
||||
return !args.some(arg => DANGEROUS_FLAGS.has(arg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick health check for a custom MCP server.
|
||||
* For HTTP servers: makes a HEAD/GET request to check connectivity.
|
||||
* For command servers: checks if the command exists.
|
||||
*/
|
||||
async function checkMcpHealth(server: CustomMcpServer): Promise<McpHealthCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (server.type === 'http') {
|
||||
return checkHttpHealth(server, startTime);
|
||||
} else {
|
||||
return checkCommandHealth(server, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check HTTP server health by making a request.
|
||||
*/
|
||||
async function checkHttpHealth(server: CustomMcpServer, startTime: number): Promise<McpHealthCheckResult> {
|
||||
if (!server.url) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: 'No URL configured',
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// Add custom headers if configured
|
||||
if (server.headers) {
|
||||
Object.assign(headers, server.headers);
|
||||
}
|
||||
|
||||
const response = await fetch(server.url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
let status: McpHealthStatus;
|
||||
let message: string;
|
||||
|
||||
if (response.ok) {
|
||||
status = 'healthy';
|
||||
message = 'Server is responding';
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
status = 'needs_auth';
|
||||
message = response.status === 401 ? 'Authentication required' : 'Access forbidden';
|
||||
} else {
|
||||
status = 'unhealthy';
|
||||
message = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
status,
|
||||
statusCode: response.status,
|
||||
message,
|
||||
responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Check for specific error types
|
||||
const status: McpHealthStatus = 'unhealthy';
|
||||
let message = errorMessage;
|
||||
|
||||
if (errorMessage.includes('abort') || errorMessage.includes('timeout')) {
|
||||
message = 'Connection timed out';
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
message = 'Connection refused - server may be down';
|
||||
} else if (errorMessage.includes('ENOTFOUND')) {
|
||||
message = 'Server not found - check URL';
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
status,
|
||||
message,
|
||||
responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check command-based server health by verifying the command exists.
|
||||
*/
|
||||
async function checkCommandHealth(server: CustomMcpServer, startTime: number): Promise<McpHealthCheckResult> {
|
||||
if (!server.command) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: 'No command configured',
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Defense-in-depth: Validate command and args before spawn
|
||||
if (!isCommandSafe(server.command)) {
|
||||
return resolve({
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: `Invalid command '${server.command}' - not in allowlist`,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (!areArgsSafe(server.args)) {
|
||||
return resolve({
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: 'Args contain dangerous interpreter flags',
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const command = process.platform === 'win32' ? 'where' : 'which';
|
||||
const proc = spawn(command, [server.command!], {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
let found = false;
|
||||
|
||||
proc.on('close', (code) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (code === 0 || found) {
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
status: 'healthy',
|
||||
message: `Command '${server.command}' found`,
|
||||
responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: `Command '${server.command}' not found in PATH`,
|
||||
responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.stdout.on('data', () => {
|
||||
found = true;
|
||||
});
|
||||
|
||||
proc.on('error', () => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
status: 'unhealthy',
|
||||
message: `Failed to check command '${server.command}'`,
|
||||
responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full MCP connection test - actually connects to the server and tries to list tools.
|
||||
* This is more thorough but slower than the health check.
|
||||
*/
|
||||
async function testMcpConnection(server: CustomMcpServer): Promise<McpTestConnectionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (server.type === 'http') {
|
||||
return testHttpConnection(server, startTime);
|
||||
} else {
|
||||
return testCommandConnection(server, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test HTTP MCP server connection by sending an MCP initialize request.
|
||||
*/
|
||||
async function testHttpConnection(server: CustomMcpServer, startTime: number): Promise<McpTestConnectionResult> {
|
||||
if (!server.url) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'No URL configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (server.headers) {
|
||||
Object.assign(headers, server.headers);
|
||||
}
|
||||
|
||||
// Send MCP initialize request
|
||||
const initRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'auto-claude-health-check',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(server.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(initRequest),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'Authentication failed',
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: `Server returned error`,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'MCP error',
|
||||
error: data.error.message || JSON.stringify(data.error),
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Now try to list tools
|
||||
const toolsRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
const toolsResponse = await fetch(server.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(toolsRequest),
|
||||
});
|
||||
|
||||
let tools: string[] = [];
|
||||
if (toolsResponse.ok) {
|
||||
const toolsData = await toolsResponse.json();
|
||||
if (toolsData.result?.tools) {
|
||||
tools = toolsData.result.tools.map((t: { name: string }) => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: true,
|
||||
message: tools.length > 0 ? `Connected successfully, ${tools.length} tools available` : 'Connected successfully',
|
||||
tools,
|
||||
responseTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
let message = 'Connection failed';
|
||||
if (errorMessage.includes('abort') || errorMessage.includes('timeout')) {
|
||||
message = 'Connection timed out';
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
message = 'Connection refused - server may be down';
|
||||
} else if (errorMessage.includes('ENOTFOUND')) {
|
||||
message = 'Server not found - check URL';
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message,
|
||||
error: errorMessage,
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test command-based MCP server connection by spawning the process and trying to communicate.
|
||||
*/
|
||||
async function testCommandConnection(server: CustomMcpServer, startTime: number): Promise<McpTestConnectionResult> {
|
||||
if (!server.command) {
|
||||
return {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'No command configured',
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Defense-in-depth: Validate command and args before spawn
|
||||
if (!isCommandSafe(server.command)) {
|
||||
return resolve({
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: `Invalid command '${server.command}' - not in allowlist`,
|
||||
});
|
||||
}
|
||||
if (!areArgsSafe(server.args)) {
|
||||
return resolve({
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'Args contain dangerous interpreter flags',
|
||||
});
|
||||
}
|
||||
|
||||
const args = server.args || [];
|
||||
const proc = spawn(server.command!, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 15000, // OS-level timeout for reliable process termination
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
proc.kill();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'Connection timed out',
|
||||
responseTime,
|
||||
});
|
||||
}
|
||||
}, 15000); // 15 second timeout (matches spawn timeout)
|
||||
|
||||
// Send MCP initialize request
|
||||
const initRequest = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'auto-claude-health-check',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
}) + '\n';
|
||||
|
||||
proc.stdin.write(initRequest);
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
|
||||
// Try to parse JSON response
|
||||
try {
|
||||
const lines = stdout.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
const response = JSON.parse(line);
|
||||
if (response.id === 1 && response.result) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
proc.kill();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
success: true,
|
||||
message: 'MCP server started successfully',
|
||||
responseTime,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON yet, keep waiting
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'Failed to start server',
|
||||
error: error.message,
|
||||
responseTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
const responseTime = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
success: true,
|
||||
message: 'Server process started',
|
||||
responseTime,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: `Server exited with code ${code}`,
|
||||
error: stderr || undefined,
|
||||
responseTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register MCP IPC handlers.
|
||||
*/
|
||||
export function registerMcpHandlers(): void {
|
||||
// Quick health check
|
||||
ipcMain.handle(IPC_CHANNELS.MCP_CHECK_HEALTH, async (_event, server: CustomMcpServer) => {
|
||||
try {
|
||||
const result = await checkMcpHealth(server);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
appLog.error('MCP health check error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Health check failed',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Full connection test
|
||||
ipcMain.handle(IPC_CHANNELS.MCP_TEST_CONNECTION, async (_event, server: CustomMcpServer) => {
|
||||
try {
|
||||
const result = await testMcpConnection(server);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
appLog.error('MCP connection test error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection test failed',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ipcMain, BrowserWindow, shell } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants';
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, SupportedIDE, SupportedTerminal } from '../../../shared/types';
|
||||
import { ipcMain, BrowserWindow, shell, app } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING, MODEL_ID_MAP, THINKING_BUDGET_MAP } from '../../../shared/constants';
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
@@ -13,6 +13,45 @@ import { parsePythonCommand } from '../../python-detector';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
import { promisify } from 'util';
|
||||
|
||||
/**
|
||||
* Read utility feature settings (for commit message, merge resolver) from settings file
|
||||
*/
|
||||
function getUtilitySettings(): { model: string; modelId: string; thinkingLevel: string; thinkingBudget: number | null } {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
|
||||
|
||||
// Get utility-specific settings
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
const model = featureModels.utility || DEFAULT_FEATURE_MODELS.utility;
|
||||
const thinkingLevel = featureThinking.utility || DEFAULT_FEATURE_THINKING.utility;
|
||||
|
||||
return {
|
||||
model,
|
||||
modelId: MODEL_ID_MAP[model] || MODEL_ID_MAP.haiku,
|
||||
thinkingLevel,
|
||||
thinkingBudget: thinkingLevel in THINKING_BUDGET_MAP ? THINKING_BUDGET_MAP[thinkingLevel] : THINKING_BUDGET_MAP.low
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Log parse errors to help diagnose corrupted settings
|
||||
console.warn('[getUtilitySettings] Failed to parse settings.json:', error);
|
||||
}
|
||||
|
||||
// Return defaults if settings file doesn't exist or fails to parse
|
||||
return {
|
||||
model: DEFAULT_FEATURE_MODELS.utility,
|
||||
modelId: MODEL_ID_MAP[DEFAULT_FEATURE_MODELS.utility],
|
||||
thinkingLevel: DEFAULT_FEATURE_THINKING.utility,
|
||||
thinkingBudget: THINKING_BUDGET_MAP[DEFAULT_FEATURE_THINKING.utility]
|
||||
};
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -1453,6 +1492,10 @@ export function registerWorktreeHandlers(
|
||||
// Get Python environment for bundled packages
|
||||
const pythonEnv = pythonEnvManagerSingleton.getPythonEnv();
|
||||
|
||||
// Get utility settings for merge resolver
|
||||
const utilitySettings = getUtilitySettings();
|
||||
debug('Utility settings for merge:', utilitySettings);
|
||||
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
@@ -1462,7 +1505,11 @@ export function registerWorktreeHandlers(
|
||||
...pythonEnv, // Include bundled packages PYTHONPATH
|
||||
...profileEnv, // Include active Claude profile OAuth token
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1'
|
||||
PYTHONUTF8: '1',
|
||||
// Utility feature settings for merge resolver
|
||||
UTILITY_MODEL: utilitySettings.model,
|
||||
UTILITY_MODEL_ID: utilitySettings.modelId,
|
||||
UTILITY_THINKING_BUDGET: utilitySettings.thinkingBudget === null ? '' : (utilitySettings.thinkingBudget?.toString() || '')
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'] // Don't connect stdin to avoid blocking
|
||||
});
|
||||
@@ -1577,8 +1624,9 @@ export function registerWorktreeHandlers(
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
// git merge-base --is-ancestor returns exit code 0 if true, 1 if false
|
||||
execSync(
|
||||
`git merge-base --is-ancestor ${specBranch} HEAD`,
|
||||
execFileSync(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', specBranch, 'HEAD'],
|
||||
{ cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
// If we reach here, the command succeeded (exit code 0) - branch is merged
|
||||
|
||||
@@ -10,6 +10,8 @@ import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
import { GitHubAPI, createGitHubAPI } from './modules/github-api';
|
||||
import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api';
|
||||
import { DebugAPI, createDebugAPI } from './modules/debug-api';
|
||||
import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api';
|
||||
import { McpAPI, createMcpAPI } from './modules/mcp-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
ProjectAPI,
|
||||
@@ -22,7 +24,9 @@ export interface ElectronAPI extends
|
||||
InsightsAPI,
|
||||
AppUpdateAPI,
|
||||
GitLabAPI,
|
||||
DebugAPI {
|
||||
DebugAPI,
|
||||
ClaudeCodeAPI,
|
||||
McpAPI {
|
||||
github: GitHubAPI;
|
||||
}
|
||||
|
||||
@@ -38,6 +42,8 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createAppUpdateAPI(),
|
||||
...createGitLabAPI(),
|
||||
...createDebugAPI(),
|
||||
...createClaudeCodeAPI(),
|
||||
...createMcpAPI(),
|
||||
github: createGitHubAPI()
|
||||
});
|
||||
|
||||
@@ -54,7 +60,9 @@ export {
|
||||
createAppUpdateAPI,
|
||||
createGitHubAPI,
|
||||
createGitLabAPI,
|
||||
createDebugAPI
|
||||
createDebugAPI,
|
||||
createClaudeCodeAPI,
|
||||
createMcpAPI
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -69,5 +77,7 @@ export type {
|
||||
AppUpdateAPI,
|
||||
GitHubAPI,
|
||||
GitLabAPI,
|
||||
DebugAPI
|
||||
DebugAPI,
|
||||
ClaudeCodeAPI,
|
||||
McpAPI
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Claude Code API for renderer process
|
||||
*
|
||||
* Provides access to Claude Code CLI management:
|
||||
* - Check installed vs latest version
|
||||
* - Install or update Claude Code
|
||||
*/
|
||||
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { ClaudeCodeVersionInfo } from '../../../shared/types/cli';
|
||||
import { invokeIpc } from './ipc-utils';
|
||||
|
||||
/**
|
||||
* Result of Claude Code installation attempt
|
||||
*/
|
||||
export interface ClaudeCodeInstallResult {
|
||||
success: boolean;
|
||||
data?: {
|
||||
command: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of version check
|
||||
*/
|
||||
export interface ClaudeCodeVersionResult {
|
||||
success: boolean;
|
||||
data?: ClaudeCodeVersionInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code API interface exposed to renderer
|
||||
*/
|
||||
export interface ClaudeCodeAPI {
|
||||
/**
|
||||
* Check Claude Code CLI version status
|
||||
* Returns installed version, latest version, and whether update is available
|
||||
*/
|
||||
checkClaudeCodeVersion: () => Promise<ClaudeCodeVersionResult>;
|
||||
|
||||
/**
|
||||
* Install or update Claude Code CLI
|
||||
* Opens the user's terminal with the install command
|
||||
*/
|
||||
installClaudeCode: () => Promise<ClaudeCodeInstallResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Claude Code API implementation
|
||||
*/
|
||||
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
|
||||
checkClaudeCodeVersion: (): Promise<ClaudeCodeVersionResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
|
||||
|
||||
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL)
|
||||
});
|
||||
@@ -248,6 +248,9 @@ export interface GitHubAPI {
|
||||
checkNewCommits: (projectId: string, prNumber: number) => Promise<NewCommitsCheck>;
|
||||
runFollowupReview: (projectId: string, prNumber: number) => void;
|
||||
|
||||
// PR logs
|
||||
getPRLogs: (projectId: string, prNumber: number) => Promise<PRLogs | null>;
|
||||
|
||||
// PR event listeners
|
||||
onPRReviewProgress: (
|
||||
callback: (projectId: string, progress: PRReviewProgress) => void
|
||||
@@ -336,6 +339,8 @@ export interface NewCommitsCheck {
|
||||
newCommitCount: number;
|
||||
lastReviewedCommit?: string;
|
||||
currentHeadCommit?: string;
|
||||
/** Whether new commits happened AFTER findings were posted (for "Ready for Follow-up" status) */
|
||||
hasCommitsAfterPosting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +353,56 @@ export interface PRReviewProgress {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR review log entry type
|
||||
*/
|
||||
export type PRLogEntryType = 'text' | 'tool_start' | 'tool_end' | 'phase_start' | 'phase_end' | 'error' | 'success' | 'info';
|
||||
|
||||
/**
|
||||
* PR review log phase
|
||||
*/
|
||||
export type PRLogPhase = 'context' | 'analysis' | 'synthesis';
|
||||
|
||||
/**
|
||||
* Single log entry in PR review
|
||||
*/
|
||||
export interface PRLogEntry {
|
||||
timestamp: string;
|
||||
type: PRLogEntryType;
|
||||
content: string;
|
||||
phase: PRLogPhase;
|
||||
source?: string; // e.g., 'Context', 'AI', 'Orchestrator', 'ParallelFollowup'
|
||||
detail?: string; // Expandable detail content
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase log containing entries
|
||||
*/
|
||||
export interface PRPhaseLog {
|
||||
phase: PRLogPhase;
|
||||
status: 'pending' | 'active' | 'completed' | 'failed';
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
entries: PRLogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete PR review logs
|
||||
*/
|
||||
export interface PRLogs {
|
||||
pr_number: number;
|
||||
repo: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_followup: boolean;
|
||||
phases: {
|
||||
context: PRPhaseLog;
|
||||
analysis: PRPhaseLog;
|
||||
synthesis: PRPhaseLog;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the GitHub Integration API implementation
|
||||
*/
|
||||
@@ -564,6 +619,10 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
runFollowupReview: (projectId: string, prNumber: number): void =>
|
||||
sendIpc(IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW, projectId, prNumber),
|
||||
|
||||
// PR logs
|
||||
getPRLogs: (projectId: string, prNumber: number): Promise<PRLogs | null> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_LOGS, projectId, prNumber),
|
||||
|
||||
// PR event listeners
|
||||
onPRReviewProgress: (
|
||||
callback: (projectId: string, progress: PRReviewProgress) => void
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface GitLabAPI {
|
||||
|
||||
// OAuth operations (glab CLI)
|
||||
checkGitLabCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
|
||||
installGitLabCli: () => Promise<IPCResult<{ command: string }>>;
|
||||
checkGitLabAuth: (instanceUrl?: string) => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
|
||||
startGitLabAuth: (instanceUrl?: string) => Promise<IPCResult<{ deviceCode: string; verificationUrl: string; userCode: string }>>;
|
||||
getGitLabToken: (instanceUrl?: string) => Promise<IPCResult<{ token: string }>>;
|
||||
@@ -397,6 +398,9 @@ export const createGitLabAPI = (): GitLabAPI => ({
|
||||
checkGitLabCli: (): Promise<IPCResult<{ installed: boolean; version?: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITLAB_CHECK_CLI),
|
||||
|
||||
installGitLabCli: (): Promise<IPCResult<{ command: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITLAB_INSTALL_CLI),
|
||||
|
||||
checkGitLabAuth: (instanceUrl?: string): Promise<IPCResult<{ authenticated: boolean; username?: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITLAB_CHECK_AUTH, instanceUrl),
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* MCP Server API
|
||||
*
|
||||
* Exposes MCP health check and connection test functionality to the renderer.
|
||||
*/
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants/ipc';
|
||||
import type { IPCResult } from '../../../shared/types/common';
|
||||
import type { CustomMcpServer, McpHealthCheckResult, McpTestConnectionResult } from '../../../shared/types/project';
|
||||
|
||||
export interface McpAPI {
|
||||
/** Quick health check for a custom MCP server */
|
||||
checkMcpHealth: (server: CustomMcpServer) => Promise<IPCResult<McpHealthCheckResult>>;
|
||||
/** Full MCP connection test */
|
||||
testMcpConnection: (server: CustomMcpServer) => Promise<IPCResult<McpTestConnectionResult>>;
|
||||
}
|
||||
|
||||
export function createMcpAPI(): McpAPI {
|
||||
return {
|
||||
checkMcpHealth: (server: CustomMcpServer) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.MCP_CHECK_HEALTH, server),
|
||||
|
||||
testMcpConnection: (server: CustomMcpServer) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.MCP_TEST_CONNECTION, server),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Terminal, Check, AlertTriangle, X, Loader2, Download, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from './ui/popover';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from './ui/tooltip';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from './ui/alert-dialog';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { ClaudeCodeVersionInfo } from '../../shared/types/cli';
|
||||
|
||||
interface ClaudeCodeStatusBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type StatusType = 'loading' | 'installed' | 'outdated' | 'not-found' | 'error';
|
||||
|
||||
// Check every 24 hours
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Claude Code CLI status badge for the sidebar.
|
||||
* Shows installation status and provides quick access to install/update.
|
||||
*/
|
||||
export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) {
|
||||
const { t } = useTranslation(['common', 'navigation']);
|
||||
const [status, setStatus] = useState<StatusType>('loading');
|
||||
const [versionInfo, setVersionInfo] = useState<ClaudeCodeVersionInfo | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showUpdateWarning, setShowUpdateWarning] = useState(false);
|
||||
|
||||
// Check Claude Code version
|
||||
const checkVersion = useCallback(async () => {
|
||||
try {
|
||||
if (!window.electronAPI?.checkClaudeCodeVersion) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.checkClaudeCodeVersion();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setVersionInfo(result.data);
|
||||
setLastChecked(new Date());
|
||||
|
||||
if (!result.data.installed) {
|
||||
setStatus('not-found');
|
||||
} else if (result.data.isOutdated) {
|
||||
setStatus('outdated');
|
||||
} else {
|
||||
setStatus('installed');
|
||||
}
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check Claude Code version:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial check and periodic re-check
|
||||
useEffect(() => {
|
||||
checkVersion();
|
||||
|
||||
// Set up periodic check
|
||||
const interval = setInterval(() => {
|
||||
checkVersion();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [checkVersion]);
|
||||
|
||||
// Perform the actual install/update
|
||||
const performInstall = async () => {
|
||||
setIsInstalling(true);
|
||||
setShowUpdateWarning(false);
|
||||
try {
|
||||
if (!window.electronAPI?.installClaudeCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.installClaudeCode();
|
||||
|
||||
if (result.success) {
|
||||
// Re-check after a delay
|
||||
setTimeout(() => {
|
||||
checkVersion();
|
||||
}, 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to install Claude Code:', err);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle install/update button click
|
||||
const handleInstall = () => {
|
||||
if (status === 'outdated') {
|
||||
// Show warning for updates since it will close running Claude sessions
|
||||
setShowUpdateWarning(true);
|
||||
} else {
|
||||
// Fresh install - no warning needed
|
||||
performInstall();
|
||||
}
|
||||
};
|
||||
|
||||
// Get status indicator color
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'installed':
|
||||
return 'bg-green-500';
|
||||
case 'outdated':
|
||||
return 'bg-yellow-500';
|
||||
case 'not-found':
|
||||
case 'error':
|
||||
return 'bg-destructive';
|
||||
default:
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case 'installed':
|
||||
return <Check className="h-3 w-3" />;
|
||||
case 'outdated':
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
case 'not-found':
|
||||
return <X className="h-3 w-3" />;
|
||||
case 'error':
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get tooltip text
|
||||
const getTooltipText = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return t('navigation:claudeCode.checking', 'Checking Claude Code...');
|
||||
case 'installed':
|
||||
return t('navigation:claudeCode.upToDate', 'Claude Code is up to date');
|
||||
case 'outdated':
|
||||
return t('navigation:claudeCode.updateAvailable', 'Claude Code update available');
|
||||
case 'not-found':
|
||||
return t('navigation:claudeCode.notInstalled', 'Claude Code not installed');
|
||||
case 'error':
|
||||
return t('navigation:claudeCode.error', 'Error checking Claude Code');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 text-xs',
|
||||
status === 'not-found' || status === 'error' ? 'text-destructive' : '',
|
||||
status === 'outdated' ? 'text-yellow-600 dark:text-yellow-500' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full',
|
||||
getStatusColor()
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Claude Code</span>
|
||||
{status === 'outdated' && (
|
||||
<span className="ml-auto text-[10px] bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-1.5 py-0.5 rounded">
|
||||
{t('common:update', 'Update')}
|
||||
</span>
|
||||
)}
|
||||
{status === 'not-found' && (
|
||||
<span className="ml-auto text-[10px] bg-destructive/20 text-destructive px-1.5 py-0.5 rounded">
|
||||
{t('common:install', 'Install')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{getTooltipText()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent side="right" align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Terminal className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Claude Code CLI</h4>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{getStatusIcon()}
|
||||
{status === 'installed' && t('navigation:claudeCode.installed', 'Installed')}
|
||||
{status === 'outdated' && t('navigation:claudeCode.outdated', 'Update available')}
|
||||
{status === 'not-found' && t('navigation:claudeCode.missing', 'Not installed')}
|
||||
{status === 'loading' && t('navigation:claudeCode.checking', 'Checking...')}
|
||||
{status === 'error' && t('navigation:claudeCode.error', 'Error')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
{versionInfo && status !== 'loading' && (
|
||||
<div className="text-xs space-y-1 p-2 bg-muted rounded-md">
|
||||
{versionInfo.installed && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('navigation:claudeCode.current', 'Current')}:</span>
|
||||
<span className="font-mono">{versionInfo.installed}</span>
|
||||
</div>
|
||||
)}
|
||||
{versionInfo.latest && versionInfo.latest !== 'unknown' && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('navigation:claudeCode.latest', 'Latest')}:</span>
|
||||
<span className="font-mono">{versionInfo.latest}</span>
|
||||
</div>
|
||||
)}
|
||||
{lastChecked && (
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>{t('navigation:claudeCode.lastChecked', 'Last checked')}:</span>
|
||||
<span>{lastChecked.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{(status === 'not-found' || status === 'outdated') && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 gap-1"
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3 w-3" />
|
||||
)}
|
||||
{status === 'outdated'
|
||||
? t('common:update', 'Update')
|
||||
: t('common:install', 'Install')
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => checkVersion()}
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', status === 'loading' && 'animate-spin')} />
|
||||
{t('common:refresh', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Learn more link */}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground gap-1"
|
||||
onClick={() => window.electronAPI?.openExternal?.('https://claude.ai/code')}
|
||||
>
|
||||
{t('navigation:claudeCode.learnMore', 'Learn more about Claude Code')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
{/* Update warning dialog */}
|
||||
<AlertDialog open={showUpdateWarning} onOpenChange={setShowUpdateWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('navigation:claudeCode.updateWarningTitle', 'Update Claude Code?')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('navigation:claudeCode.updateWarningDescription', 'Updating will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t('common:cancel', 'Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={performInstall}>
|
||||
{t('navigation:claudeCode.updateAnyway', 'Update Anyway')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Custom MCP Server Dialog
|
||||
*
|
||||
* Dialog for adding/editing custom MCP servers.
|
||||
* Supports both command-based (npx/npm) and HTTP-based servers.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CustomMcpServer } from '../../shared/types';
|
||||
import { Terminal, Globe, X, Github, Loader2, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface CustomMcpDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
server: CustomMcpServer | null; // null = create new, non-null = edit
|
||||
existingIds: string[]; // Existing server IDs for validation
|
||||
onSave: (server: CustomMcpServer) => void;
|
||||
}
|
||||
|
||||
export function CustomMcpDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
server,
|
||||
existingIds,
|
||||
onSave
|
||||
}: CustomMcpDialogProps) {
|
||||
const { t } = useTranslation(['settings', 'common']);
|
||||
const isEditing = server !== null;
|
||||
|
||||
const [formData, setFormData] = useState<CustomMcpServer>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'command',
|
||||
command: 'npx',
|
||||
args: [],
|
||||
url: '',
|
||||
headers: {},
|
||||
description: '',
|
||||
});
|
||||
|
||||
const [argsInput, setArgsInput] = useState('');
|
||||
const [headerKey, setHeaderKey] = useState('');
|
||||
const [headerValue, setHeaderValue] = useState('');
|
||||
const [bearerToken, setBearerToken] = useState('');
|
||||
const [showAdvancedHeaders, setShowAdvancedHeaders] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Known provider patterns for helpful hints
|
||||
const urlHint = useMemo(() => {
|
||||
const url = formData.url?.toLowerCase() || '';
|
||||
if (url.includes('github')) {
|
||||
return {
|
||||
provider: 'GitHub',
|
||||
icon: Github,
|
||||
message: t('mcp.hints.github'),
|
||||
link: 'https://github.com/settings/tokens',
|
||||
linkText: t('mcp.hints.createGithubPat'),
|
||||
};
|
||||
}
|
||||
if (url.includes('google') || url.includes('googleapis')) {
|
||||
return {
|
||||
provider: 'Google',
|
||||
icon: Globe,
|
||||
message: t('mcp.hints.google'),
|
||||
link: 'https://console.cloud.google.com/apis/credentials',
|
||||
linkText: t('mcp.hints.createGoogleToken'),
|
||||
};
|
||||
}
|
||||
if (url.includes('anthropic')) {
|
||||
return {
|
||||
provider: 'Anthropic',
|
||||
icon: Globe,
|
||||
message: t('mcp.hints.anthropic'),
|
||||
link: 'https://console.anthropic.com/settings/keys',
|
||||
linkText: t('mcp.hints.createAnthropicKey'),
|
||||
};
|
||||
}
|
||||
if (url.includes('openai')) {
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
icon: Globe,
|
||||
message: t('mcp.hints.openai'),
|
||||
link: 'https://platform.openai.com/api-keys',
|
||||
linkText: t('mcp.hints.createOpenaiKey'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [formData.url, t]);
|
||||
|
||||
// Reset form when dialog opens/closes or server changes
|
||||
useEffect(() => {
|
||||
if (open && server) {
|
||||
setFormData(server);
|
||||
setArgsInput(server.args?.join(' ') || '');
|
||||
// Extract bearer token from existing Authorization header
|
||||
const authHeader = server.headers?.['Authorization'] || server.headers?.['authorization'] || '';
|
||||
if (authHeader.toLowerCase().startsWith('bearer ')) {
|
||||
setBearerToken(authHeader.substring(7));
|
||||
} else {
|
||||
setBearerToken('');
|
||||
}
|
||||
// Show advanced headers if there are non-Authorization headers
|
||||
const hasOtherHeaders = Object.keys(server.headers || {}).some(
|
||||
k => k.toLowerCase() !== 'authorization'
|
||||
);
|
||||
setShowAdvancedHeaders(hasOtherHeaders);
|
||||
setError(null);
|
||||
} else if (open) {
|
||||
setFormData({
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'command',
|
||||
command: 'npx',
|
||||
args: [],
|
||||
url: '',
|
||||
headers: {},
|
||||
description: '',
|
||||
});
|
||||
setArgsInput('');
|
||||
setBearerToken('');
|
||||
setShowAdvancedHeaders(false);
|
||||
setError(null);
|
||||
}
|
||||
setHeaderKey('');
|
||||
setHeaderValue('');
|
||||
}, [open, server]);
|
||||
|
||||
// Generate ID from name
|
||||
const generateId = (name: string): string => {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('mcp.errorNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedId = isEditing ? formData.id : generateId(formData.name);
|
||||
|
||||
// Check for duplicate ID (only when creating new)
|
||||
if (!isEditing && existingIds.includes(generatedId)) {
|
||||
setError(t('mcp.errorIdExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.type === 'command' && !formData.command?.trim()) {
|
||||
setError(t('mcp.errorCommandRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.type === 'http' && !formData.url?.trim()) {
|
||||
setError(t('mcp.errorUrlRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build headers, merging bearer token if provided
|
||||
const finalHeaders: Record<string, string> = {};
|
||||
if (formData.type === 'http') {
|
||||
// Start with existing headers (excluding old Authorization if we have a new bearer token)
|
||||
if (formData.headers) {
|
||||
for (const [key, value] of Object.entries(formData.headers)) {
|
||||
if (bearerToken && key.toLowerCase() === 'authorization') {
|
||||
continue; // Skip old auth header if we have a new bearer token
|
||||
}
|
||||
finalHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
// Add bearer token as Authorization header
|
||||
if (bearerToken.trim()) {
|
||||
finalHeaders['Authorization'] = `Bearer ${bearerToken.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const serverToSave: CustomMcpServer = {
|
||||
id: generatedId,
|
||||
name: formData.name.trim(),
|
||||
type: formData.type,
|
||||
description: formData.description?.trim() || undefined,
|
||||
...(formData.type === 'command'
|
||||
? {
|
||||
command: formData.command,
|
||||
args: argsInput.split(' ').filter(Boolean),
|
||||
}
|
||||
: {
|
||||
url: formData.url,
|
||||
headers: Object.keys(finalHeaders).length > 0 ? finalHeaders : undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
onSave(serverToSave);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
if (headerKey.trim() && headerValue.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [headerKey.trim()]: headerValue.trim() },
|
||||
}));
|
||||
setHeaderKey('');
|
||||
setHeaderValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
setFormData(prev => {
|
||||
const newHeaders = { ...prev.headers };
|
||||
delete newHeaders[key];
|
||||
return { ...prev, headers: newHeaders };
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = formData.name.trim() && (
|
||||
(formData.type === 'command' && formData.command?.trim()) ||
|
||||
(formData.type === 'http' && formData.url?.trim())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? t('mcp.editCustomServer') : t('mcp.addCustomServer')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('mcp.customServerDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('mcp.serverType')}</Label>
|
||||
<RadioGroup
|
||||
value={formData.type}
|
||||
onValueChange={(value: 'command' | 'http') =>
|
||||
setFormData(prev => ({ ...prev, type: value }))
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="command" id="type-command" />
|
||||
<Label htmlFor="type-command" className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
{t('mcp.typeCommand')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="http" id="type-http" />
|
||||
<Label htmlFor="type-http" className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{t('mcp.typeHttp')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t('mcp.serverName')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, name: e.target.value }));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t('mcp.serverNamePlaceholder')}
|
||||
/>
|
||||
{!isEditing && formData.name && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ID: {generateId(formData.name) || '...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
{t('mcp.serverDescription')} <span className="text-muted-foreground">({t('common:optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t('mcp.serverDescriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Command-based fields */}
|
||||
{formData.type === 'command' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="command">{t('mcp.command')}</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={formData.command || ''}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, command: e.target.value }));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="args">{t('mcp.args')}</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={argsInput}
|
||||
onChange={(e) => setArgsInput(e.target.value)}
|
||||
placeholder="-y @myorg/my-mcp-server"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('mcp.argsHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HTTP-based fields */}
|
||||
{formData.type === 'http' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">{t('mcp.url')}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={formData.url || ''}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, url: e.target.value }));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL-based hint for known providers */}
|
||||
{urlHint && (
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<urlHint.icon className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{urlHint.message}</p>
|
||||
<a
|
||||
href={urlHint.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline mt-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.electronAPI?.openExternal(urlHint.link);
|
||||
}}
|
||||
>
|
||||
{urlHint.linkText}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication Token (simplified) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bearerToken">
|
||||
{t('mcp.authToken')} <span className="text-muted-foreground">({t('common:optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="bearerToken"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
placeholder={t('mcp.authTokenPlaceholder')}
|
||||
type="password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('mcp.authTokenHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Headers (collapsible) */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvancedHeaders(!showAdvancedHeaders)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className={`transition-transform ${showAdvancedHeaders ? 'rotate-90' : ''}`}>▶</span>
|
||||
{t('mcp.advancedHeaders')}
|
||||
</button>
|
||||
|
||||
{showAdvancedHeaders && (
|
||||
<div className="pl-4 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={headerKey}
|
||||
onChange={(e) => setHeaderKey(e.target.value)}
|
||||
placeholder={t('mcp.headerName')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={headerValue}
|
||||
onChange={(e) => setHeaderValue(e.target.value)}
|
||||
placeholder={t('mcp.headerValue')}
|
||||
className="flex-1"
|
||||
type="password"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addHeader}
|
||||
disabled={!headerKey.trim() || !headerValue.trim()}
|
||||
>
|
||||
{t('common:add')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Show non-Authorization headers */}
|
||||
{Object.entries(formData.headers || {}).filter(([key]) => key.toLowerCase() !== 'authorization').length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{Object.entries(formData.headers || {})
|
||||
.filter(([key]) => key.toLowerCase() !== 'authorization')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm bg-muted px-2 py-1 rounded">
|
||||
<span>
|
||||
<span className="font-medium">{key}:</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{value.length > 20 ? `${value.substring(0, 20)}...` : value}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid}>
|
||||
{isEditing ? t('common:save') : t('mcp.addServer')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArc
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex w-72 shrink-0 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
'flex min-w-72 max-w-[30rem] flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
getColumnBorderColor(),
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
|
||||
@@ -84,7 +84,7 @@ function DroppableStatusColumn({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex min-w-80 w-80 shrink-0 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
'flex min-w-80 max-w-[32rem] flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
column.color,
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus,
|
||||
@@ -49,7 +49,8 @@ import { useSettingsStore } from '../stores/settings-store';
|
||||
import { AddProjectModal } from './AddProjectModal';
|
||||
import { GitSetupModal } from './GitSetupModal';
|
||||
import { RateLimitIndicator } from './RateLimitIndicator';
|
||||
import type { Project, AutoBuildVersionInfo, GitStatus } from '../../shared/types';
|
||||
import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge';
|
||||
import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types';
|
||||
|
||||
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools';
|
||||
|
||||
@@ -67,23 +68,29 @@ interface NavItem {
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const projectNavItems: NavItem[] = [
|
||||
// Base nav items always shown
|
||||
const baseNavItems: NavItem[] = [
|
||||
{ id: 'kanban', labelKey: 'navigation:items.kanban', icon: LayoutGrid, shortcut: 'K' },
|
||||
{ id: 'terminals', labelKey: 'navigation:items.terminals', icon: Terminal, shortcut: 'A' },
|
||||
{ id: 'insights', labelKey: 'navigation:items.insights', icon: Sparkles, shortcut: 'N' },
|
||||
{ id: 'roadmap', labelKey: 'navigation:items.roadmap', icon: Map, shortcut: 'D' },
|
||||
{ id: 'ideation', labelKey: 'navigation:items.ideation', icon: Lightbulb, shortcut: 'I' },
|
||||
{ id: 'changelog', labelKey: 'navigation:items.changelog', icon: FileText, shortcut: 'L' },
|
||||
{ id: 'context', labelKey: 'navigation:items.context', icon: BookOpen, shortcut: 'C' }
|
||||
{ id: 'context', labelKey: 'navigation:items.context', icon: BookOpen, shortcut: 'C' },
|
||||
{ id: 'agent-tools', labelKey: 'navigation:items.agentTools', icon: Wrench, shortcut: 'M' },
|
||||
{ id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' }
|
||||
];
|
||||
|
||||
const toolsNavItems: NavItem[] = [
|
||||
// GitHub nav items shown when GitHub is enabled
|
||||
const githubNavItems: NavItem[] = [
|
||||
{ id: 'github-issues', labelKey: 'navigation:items.githubIssues', icon: Github, shortcut: 'G' },
|
||||
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'P' }
|
||||
];
|
||||
|
||||
// GitLab nav items shown when GitLab is enabled
|
||||
const gitlabNavItems: NavItem[] = [
|
||||
{ id: 'gitlab-issues', labelKey: 'navigation:items.gitlabIssues', icon: GitlabIcon, shortcut: 'B' },
|
||||
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'P' },
|
||||
{ id: 'gitlab-merge-requests', labelKey: 'navigation:items.gitlabMRs', icon: GitMerge, shortcut: 'R' },
|
||||
{ id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' },
|
||||
{ id: 'agent-tools', labelKey: 'navigation:items.agentTools', icon: Wrench, shortcut: 'M' }
|
||||
{ id: 'gitlab-merge-requests', labelKey: 'navigation:items.gitlabMRs', icon: GitMerge, shortcut: 'R' }
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -104,9 +111,46 @@ export function Sidebar({
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [pendingProject, setPendingProject] = useState<Project | null>(null);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [envConfig, setEnvConfig] = useState<ProjectEnvConfig | null>(null);
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
// Load env config when project changes to check GitHub/GitLab enabled state
|
||||
useEffect(() => {
|
||||
const loadEnvConfig = async () => {
|
||||
if (selectedProject?.autoBuildPath) {
|
||||
try {
|
||||
const result = await window.electronAPI.getProjectEnv(selectedProject.id);
|
||||
if (result.success && result.data) {
|
||||
setEnvConfig(result.data);
|
||||
} else {
|
||||
setEnvConfig(null);
|
||||
}
|
||||
} catch {
|
||||
setEnvConfig(null);
|
||||
}
|
||||
} else {
|
||||
setEnvConfig(null);
|
||||
}
|
||||
};
|
||||
loadEnvConfig();
|
||||
}, [selectedProject?.id, selectedProject?.autoBuildPath]);
|
||||
|
||||
// Compute visible nav items based on GitHub/GitLab enabled state
|
||||
const visibleNavItems = useMemo(() => {
|
||||
const items = [...baseNavItems];
|
||||
|
||||
if (envConfig?.githubEnabled) {
|
||||
items.push(...githubNavItems);
|
||||
}
|
||||
|
||||
if (envConfig?.gitlabEnabled) {
|
||||
items.push(...gitlabNavItems);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [envConfig?.githubEnabled, envConfig?.gitlabEnabled]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -128,9 +172,8 @@ export function Sidebar({
|
||||
|
||||
const key = e.key.toUpperCase();
|
||||
|
||||
// Find matching nav item
|
||||
const allNavItems = [...projectNavItems, ...toolsNavItems];
|
||||
const matchedItem = allNavItems.find((item) => item.shortcut === key);
|
||||
// Find matching nav item from visible items only
|
||||
const matchedItem = visibleNavItems.find((item) => item.shortcut === key);
|
||||
|
||||
if (matchedItem) {
|
||||
e.preventDefault();
|
||||
@@ -140,7 +183,7 @@ export function Sidebar({
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedProjectId, onViewChange]);
|
||||
}, [selectedProjectId, onViewChange, visibleNavItems]);
|
||||
|
||||
// Check git status when project changes
|
||||
useEffect(() => {
|
||||
@@ -268,22 +311,12 @@ export function Sidebar({
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3 py-4">
|
||||
{/* Project Section */}
|
||||
<div className="mb-6">
|
||||
<div>
|
||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('sections.project')}
|
||||
</h3>
|
||||
<nav className="space-y-1">
|
||||
{projectNavItems.map(renderNavItem)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tools Section */}
|
||||
<div>
|
||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('sections.tools')}
|
||||
</h3>
|
||||
<nav className="space-y-1">
|
||||
{toolsNavItems.map(renderNavItem)}
|
||||
{visibleNavItems.map(renderNavItem)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,6 +329,9 @@ export function Sidebar({
|
||||
|
||||
{/* Bottom section with Settings, Help, and New Task */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Claude Code Status Badge */}
|
||||
<ClaudeCodeStatusBadge />
|
||||
|
||||
{/* Settings and Help row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@@ -192,7 +192,7 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
>
|
||||
{task.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[160px]">
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[180px]">
|
||||
{/* Stuck indicator - highest priority */}
|
||||
{isStuck && (
|
||||
<Badge
|
||||
|
||||
@@ -150,6 +150,13 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
}
|
||||
}, [selectedPRNumber, assignPR]);
|
||||
|
||||
const handleGetLogs = useCallback(async () => {
|
||||
if (selectedProjectId && selectedPRNumber) {
|
||||
return await window.electronAPI.github.getPRLogs(selectedProjectId, selectedPRNumber);
|
||||
}
|
||||
return null;
|
||||
}, [selectedProjectId, selectedPRNumber]);
|
||||
|
||||
// Not connected state
|
||||
if (!isConnected) {
|
||||
return <NotConnectedState error={error} onOpenSettings={onOpenSettings} t={t} />;
|
||||
@@ -233,6 +240,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
onPostComment={handlePostComment}
|
||||
onMergePR={handleMergePR}
|
||||
onAssignPR={handleAssignPR}
|
||||
onGetLogs={handleGetLogs}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message={t('prReview.selectPRToView')} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCheck,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -25,9 +26,10 @@ import { CollapsibleCard } from './CollapsibleCard';
|
||||
import { ReviewStatusTree } from './ReviewStatusTree';
|
||||
import { PRHeader } from './PRHeader';
|
||||
import { ReviewFindings } from './ReviewFindings';
|
||||
import { PRLogs } from './PRLogs';
|
||||
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
import type { NewCommitsCheck, PRLogs as PRLogsType } from '../../../../preload/api/modules/github-api';
|
||||
|
||||
interface PRDetailProps {
|
||||
pr: PRData;
|
||||
@@ -44,6 +46,7 @@ interface PRDetailProps {
|
||||
onPostComment: (body: string) => void;
|
||||
onMergePR: (mergeMethod?: 'merge' | 'squash' | 'rebase') => void;
|
||||
onAssignPR: (username: string) => void;
|
||||
onGetLogs: () => Promise<PRLogsType | null>;
|
||||
}
|
||||
|
||||
function getStatusColor(status: PRReviewResult['overallStatus']): string {
|
||||
@@ -72,6 +75,7 @@ export function PRDetail({
|
||||
onPostComment,
|
||||
onMergePR,
|
||||
onAssignPR: _onAssignPR,
|
||||
onGetLogs,
|
||||
}: PRDetailProps) {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
// Selection state for findings
|
||||
@@ -87,6 +91,11 @@ export function PRDetail({
|
||||
const checkNewCommitsAbortRef = useRef<AbortController | null>(null);
|
||||
// Ref to track checking state without causing callback recreation
|
||||
const isCheckingNewCommitsRef = useRef(false);
|
||||
// Logs state
|
||||
const [logsExpanded, setLogsExpanded] = useState(false);
|
||||
const [prLogs, setPrLogs] = useState<PRLogsType | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const logsLoadedRef = useRef(false);
|
||||
|
||||
// Sync with store's newCommitsCheck when it changes (e.g., when switching PRs)
|
||||
useEffect(() => {
|
||||
@@ -104,18 +113,19 @@ export function PRDetail({
|
||||
}
|
||||
}, [reviewResult?.postedFindingIds, pr.number]);
|
||||
|
||||
// Auto-select critical and high findings when review completes (excluding already posted)
|
||||
// Auto-select ALL findings when review completes (excluding already posted)
|
||||
// All findings should reach the contributor - even LOW suggestions are valuable feedback
|
||||
useEffect(() => {
|
||||
if (reviewResult?.success && reviewResult.findings.length > 0) {
|
||||
const importantFindings = reviewResult.findings
|
||||
.filter(f => (f.severity === 'critical' || f.severity === 'high') && !postedFindingIds.has(f.id))
|
||||
const allFindings = reviewResult.findings
|
||||
.filter(f => !postedFindingIds.has(f.id))
|
||||
.map(f => f.id);
|
||||
setSelectedFindingIds(new Set(importantFindings));
|
||||
setSelectedFindingIds(new Set(allFindings));
|
||||
}
|
||||
}, [reviewResult, postedFindingIds]);
|
||||
|
||||
// Check for new commits only when findings have been posted to GitHub
|
||||
// Follow-up review only makes sense after initial findings are shared with the contributor
|
||||
// Check for new commits after any review has been completed
|
||||
// This allows detecting new work pushed after ANY review (initial or follow-up)
|
||||
const hasPostedFindings = postedFindingIds.size > 0 || reviewResult?.hasPostedFindings;
|
||||
|
||||
const checkForNewCommits = useCallback(async () => {
|
||||
@@ -130,8 +140,10 @@ export function PRDetail({
|
||||
}
|
||||
checkNewCommitsAbortRef.current = new AbortController();
|
||||
|
||||
// Only check for new commits if we have a review AND findings have been posted
|
||||
if (reviewResult?.success && reviewResult.reviewedCommitSha && hasPostedFindings) {
|
||||
// Check for new commits if we have ANY successful review with a commit SHA
|
||||
// This includes follow-up reviews that resolved all issues (no new findings)
|
||||
// New commits = new code that needs to be reviewed, regardless of posting status
|
||||
if (reviewResult?.success && reviewResult.reviewedCommitSha) {
|
||||
isCheckingNewCommitsRef.current = true;
|
||||
try {
|
||||
const result = await onCheckNewCommits();
|
||||
@@ -144,11 +156,8 @@ export function PRDetail({
|
||||
isCheckingNewCommitsRef.current = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear any existing new commits check if we haven't posted yet
|
||||
setNewCommitsCheck(null);
|
||||
}
|
||||
}, [reviewResult, onCheckNewCommits, hasPostedFindings]);
|
||||
}, [reviewResult, onCheckNewCommits]);
|
||||
|
||||
useEffect(() => {
|
||||
checkForNewCommits();
|
||||
@@ -168,6 +177,70 @@ export function PRDetail({
|
||||
}
|
||||
}, [postSuccess]);
|
||||
|
||||
// Auto-expand logs section when review starts
|
||||
useEffect(() => {
|
||||
if (isReviewing) {
|
||||
setLogsExpanded(true);
|
||||
}
|
||||
}, [isReviewing]);
|
||||
|
||||
// Load logs when logs section is expanded or when reviewing (for live logs)
|
||||
useEffect(() => {
|
||||
if (logsExpanded && !logsLoadedRef.current && !isLoadingLogs) {
|
||||
logsLoadedRef.current = true;
|
||||
setIsLoadingLogs(true);
|
||||
onGetLogs()
|
||||
.then(logs => setPrLogs(logs))
|
||||
.catch(() => setPrLogs(null))
|
||||
.finally(() => setIsLoadingLogs(false));
|
||||
}
|
||||
}, [logsExpanded, onGetLogs, isLoadingLogs]);
|
||||
|
||||
// Track previous reviewing state to detect transitions
|
||||
const wasReviewingRef = useRef(false);
|
||||
|
||||
// Refresh logs periodically while reviewing (even faster during active review)
|
||||
useEffect(() => {
|
||||
const wasReviewing = wasReviewingRef.current;
|
||||
wasReviewingRef.current = isReviewing;
|
||||
|
||||
// Do one final refresh when review just completed to get final phase status
|
||||
if (wasReviewing && !isReviewing) {
|
||||
onGetLogs()
|
||||
.then(logs => setPrLogs(logs))
|
||||
.catch(err => console.error('Failed to fetch final logs:', err));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear old logs when a new review starts to avoid showing stale status
|
||||
if (!wasReviewing && isReviewing) {
|
||||
setPrLogs(null);
|
||||
}
|
||||
|
||||
if (!isReviewing) return;
|
||||
|
||||
const refreshLogs = async () => {
|
||||
try {
|
||||
const logs = await onGetLogs();
|
||||
setPrLogs(logs);
|
||||
} catch {
|
||||
// Ignore errors during refresh
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh immediately, then every 1.5 seconds while reviewing for smoother streaming
|
||||
refreshLogs();
|
||||
const interval = setInterval(refreshLogs, 1500);
|
||||
return () => clearInterval(interval);
|
||||
}, [isReviewing, onGetLogs]);
|
||||
|
||||
// Reset logs state when PR changes
|
||||
useEffect(() => {
|
||||
logsLoadedRef.current = false;
|
||||
setPrLogs(null);
|
||||
setLogsExpanded(false);
|
||||
}, [pr.number]);
|
||||
|
||||
// Count selected findings by type for the button label
|
||||
const selectedCount = selectedFindingIds.size;
|
||||
|
||||
@@ -222,6 +295,8 @@ export function PRDetail({
|
||||
const hasUnpostedBlockers = unpostedFindings.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
const hasNewCommits = newCommitsCheck?.hasNewCommits ?? false;
|
||||
const newCommitCount = newCommitsCheck?.newCommitCount ?? 0;
|
||||
// Only consider commits that happened AFTER findings were posted for "Ready for Follow-up"
|
||||
const hasCommitsAfterPosting = newCommitsCheck?.hasCommitsAfterPosting ?? false;
|
||||
|
||||
// Follow-up review specific statuses
|
||||
if (reviewResult.isFollowupReview) {
|
||||
@@ -234,8 +309,8 @@ export function PRDetail({
|
||||
f => (f.severity === 'critical' || f.severity === 'high')
|
||||
);
|
||||
|
||||
// Check if ready for another follow-up (new commits after this follow-up)
|
||||
if (hasNewCommits) {
|
||||
// Check if ready for another follow-up (new commits AFTER this follow-up was posted)
|
||||
if (hasNewCommits && hasCommitsAfterPosting) {
|
||||
return {
|
||||
status: 'ready_for_followup',
|
||||
label: t('prReview.readyForFollowup'),
|
||||
@@ -280,8 +355,8 @@ export function PRDetail({
|
||||
|
||||
// Initial review statuses (non-follow-up)
|
||||
|
||||
// Priority 1: Ready for follow-up review (posted findings + new commits)
|
||||
if (hasPosted && hasNewCommits) {
|
||||
// Priority 1: Ready for follow-up review (posted findings + new commits AFTER posting)
|
||||
if (hasPosted && hasNewCommits && hasCommitsAfterPosting) {
|
||||
return {
|
||||
status: 'ready_for_followup',
|
||||
label: t('prReview.readyForFollowup'),
|
||||
@@ -622,6 +697,35 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Logs - show during review or after completion */}
|
||||
{(reviewResult || isReviewing) && (
|
||||
<CollapsibleCard
|
||||
title={t('prReview.reviewLogs')}
|
||||
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||||
badge={
|
||||
isReviewing ? (
|
||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{t('prReview.aiReviewInProgress')}
|
||||
</Badge>
|
||||
) : prLogs ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{prLogs.is_followup ? t('prReview.followup') : t('prReview.initial')}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
open={logsExpanded}
|
||||
onOpenChange={setLogsExpanded}
|
||||
>
|
||||
<PRLogs
|
||||
prNumber={pr.number}
|
||||
logs={prLogs}
|
||||
isLoading={isLoadingLogs}
|
||||
isStreaming={isReviewing}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
@@ -22,6 +22,8 @@ interface PRStatusFlowProps {
|
||||
hasPosted: boolean;
|
||||
hasBlockingFindings: boolean;
|
||||
hasNewCommits: boolean;
|
||||
/** Whether commits happened AFTER findings were posted - for "Ready for Follow-up" status */
|
||||
hasCommitsAfterPosting: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ function PRStatusFlow({
|
||||
hasPosted,
|
||||
hasBlockingFindings,
|
||||
hasNewCommits,
|
||||
hasCommitsAfterPosting,
|
||||
t,
|
||||
}: PRStatusFlowProps) {
|
||||
// Determine flow state - prioritize more advanced states first
|
||||
@@ -51,7 +54,10 @@ function PRStatusFlow({
|
||||
|
||||
// Determine final status color for posted state
|
||||
let finalStatus: FinalStatus = 'success';
|
||||
if (hasNewCommits) {
|
||||
// Only show "Ready for Follow-up" if there are commits AFTER findings were posted
|
||||
// This prevents showing follow-up status for commits that happened during/before the review
|
||||
// hasNewCommits tells us the commits are different, hasCommitsAfterPosting tells us if they're newer
|
||||
if (hasNewCommits && hasCommitsAfterPosting) {
|
||||
finalStatus = 'followup';
|
||||
} else if (hasBlockingFindings) {
|
||||
finalStatus = 'warning';
|
||||
@@ -256,10 +262,16 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState
|
||||
// Follow-up review with no new findings to post is effectively "posted"
|
||||
(Boolean(reviewState?.result?.isFollowupReview) && reviewState?.result?.findings?.length === 0)
|
||||
}
|
||||
hasBlockingFindings={Boolean(reviewState?.result?.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
))}
|
||||
hasBlockingFindings={
|
||||
// Use overallStatus from review result as source of truth
|
||||
reviewState?.result?.overallStatus === 'request_changes' ||
|
||||
// Fallback to checking findings severity
|
||||
Boolean(reviewState?.result?.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
))
|
||||
}
|
||||
hasNewCommits={Boolean(reviewState?.newCommitsCheck?.hasNewCommits)}
|
||||
hasCommitsAfterPosting={reviewState?.newCommitsCheck?.hasCommitsAfterPosting ?? false}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Terminal,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
BrainCircuit,
|
||||
FileCheck,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type {
|
||||
PRLogs,
|
||||
PRLogPhase,
|
||||
PRPhaseLog,
|
||||
PRLogEntry
|
||||
} from '../../../../preload/api/modules/github-api';
|
||||
|
||||
interface PRLogsProps {
|
||||
prNumber: number;
|
||||
logs: PRLogs | null;
|
||||
isLoading: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<PRLogPhase, string> = {
|
||||
context: 'Context Gathering',
|
||||
analysis: 'AI Analysis',
|
||||
synthesis: 'Synthesis'
|
||||
};
|
||||
|
||||
const PHASE_ICONS: Record<PRLogPhase, typeof FolderOpen> = {
|
||||
context: FolderOpen,
|
||||
analysis: BrainCircuit,
|
||||
synthesis: FileCheck
|
||||
};
|
||||
|
||||
const PHASE_COLORS: Record<PRLogPhase, string> = {
|
||||
context: 'text-blue-500 bg-blue-500/10 border-blue-500/30',
|
||||
analysis: 'text-purple-500 bg-purple-500/10 border-purple-500/30',
|
||||
synthesis: 'text-green-500 bg-green-500/10 border-green-500/30'
|
||||
};
|
||||
|
||||
// Source colors for different log sources
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
'Context': 'bg-blue-500/20 text-blue-400',
|
||||
'AI': 'bg-purple-500/20 text-purple-400',
|
||||
'Orchestrator': 'bg-orange-500/20 text-orange-400',
|
||||
'ParallelOrchestrator': 'bg-orange-500/20 text-orange-400',
|
||||
'Followup': 'bg-cyan-500/20 text-cyan-400',
|
||||
'ParallelFollowup': 'bg-cyan-500/20 text-cyan-400',
|
||||
'BotDetector': 'bg-amber-500/20 text-amber-400',
|
||||
'Progress': 'bg-green-500/20 text-green-400',
|
||||
'PR Review Engine': 'bg-indigo-500/20 text-indigo-400',
|
||||
'Summary': 'bg-emerald-500/20 text-emerald-400',
|
||||
// Specialist agents (from parallel orchestrator)
|
||||
'Agent:logic-reviewer': 'bg-blue-600/20 text-blue-400',
|
||||
'Agent:quality-reviewer': 'bg-indigo-600/20 text-indigo-400',
|
||||
'Agent:security-reviewer': 'bg-red-600/20 text-red-400',
|
||||
'Agent:ai-triage-reviewer': 'bg-slate-500/20 text-slate-400',
|
||||
// Specialist agents (from parallel followup reviewer)
|
||||
'Agent:resolution-verifier': 'bg-teal-600/20 text-teal-400',
|
||||
'Agent:new-code-reviewer': 'bg-cyan-600/20 text-cyan-400',
|
||||
'Agent:comment-analyzer': 'bg-gray-500/20 text-gray-400',
|
||||
'default': 'bg-muted text-muted-foreground'
|
||||
};
|
||||
|
||||
export function PRLogs({ prNumber, logs, isLoading, isStreaming = false }: PRLogsProps) {
|
||||
const [expandedPhases, setExpandedPhases] = useState<Set<PRLogPhase>>(new Set(['analysis']));
|
||||
|
||||
const togglePhase = (phase: PRLogPhase) => {
|
||||
setExpandedPhases(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(phase)) {
|
||||
next.delete(phase);
|
||||
} else {
|
||||
next.add(phase);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div className="p-4 space-y-2">
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : logs ? (
|
||||
<>
|
||||
{/* Logs header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
PR #{prNumber}
|
||||
{logs.is_followup && <Badge variant="outline" className="text-xs">Follow-up</Badge>}
|
||||
{isStreaming && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30 flex items-center gap-1">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(logs.updated_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase-based collapsible logs */}
|
||||
{(['context', 'analysis', 'synthesis'] as PRLogPhase[]).map((phase) => (
|
||||
<PhaseLogSection
|
||||
key={phase}
|
||||
phase={phase}
|
||||
phaseLog={logs.phases[phase]}
|
||||
isExpanded={expandedPhases.has(phase)}
|
||||
onToggle={() => togglePhase(phase)}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : isStreaming ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-blue-500" />
|
||||
<p>Waiting for logs...</p>
|
||||
<p className="text-xs mt-1">Review is starting</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
<Terminal className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>No logs available</p>
|
||||
<p className="text-xs mt-1">Run a review to generate logs</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase Log Section Component
|
||||
interface PhaseLogSectionProps {
|
||||
phase: PRLogPhase;
|
||||
phaseLog: PRPhaseLog | null;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isStreaming = false }: PhaseLogSectionProps) {
|
||||
const Icon = PHASE_ICONS[phase];
|
||||
const status = phaseLog?.status || 'pending';
|
||||
const hasEntries = (phaseLog?.entries.length || 0) > 0;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
// Show streaming indicator for active phase during streaming
|
||||
if (status === 'active' || (isStreaming && status === 'pending')) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs bg-info/10 text-info border-info/30 flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{isStreaming ? 'Streaming' : 'Running'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs bg-success/10 text-success border-success/30 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Complete
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs bg-destructive/10 text-destructive border-destructive/30 flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs text-muted-foreground">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||
'hover:bg-secondary/50',
|
||||
status === 'active' && PHASE_COLORS[phase],
|
||||
status === 'completed' && 'border-success/30 bg-success/5',
|
||||
status === 'failed' && 'border-destructive/30 bg-destructive/5',
|
||||
status === 'pending' && 'border-border bg-secondary/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Icon className={cn('h-4 w-4', status === 'active' ? PHASE_COLORS[phase].split(' ')[0] : 'text-muted-foreground')} />
|
||||
<span className="font-medium text-sm">{PHASE_LABELS[phase]}</span>
|
||||
{hasEntries && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({phaseLog?.entries.length} entries)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 ml-6 border-l-2 border-border pl-4 py-2 space-y-1">
|
||||
{!hasEntries ? (
|
||||
<p className="text-xs text-muted-foreground italic">No logs yet</p>
|
||||
) : (
|
||||
phaseLog?.entries.map((entry, idx) => (
|
||||
<LogEntry key={`${entry.timestamp}-${idx}`} entry={entry} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Log Entry Component
|
||||
interface LogEntryProps {
|
||||
entry: PRLogEntry;
|
||||
}
|
||||
|
||||
function LogEntry({ entry }: LogEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasDetail = Boolean(entry.detail);
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceColor = (source: string | undefined) => {
|
||||
if (!source) return SOURCE_COLORS.default;
|
||||
return SOURCE_COLORS[source] || SOURCE_COLORS.default;
|
||||
};
|
||||
|
||||
if (entry.type === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-2 text-xs text-destructive bg-destructive/10 rounded-md px-2 py-1">
|
||||
<XCircle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span className="break-words flex-1">{entry.content}</span>
|
||||
{hasDetail && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded shrink-0',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors',
|
||||
isExpanded && 'bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-2.5 w-2.5" /> : <ChevronRight className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasDetail && isExpanded && (
|
||||
<div className="mt-1.5 ml-4 p-2 bg-destructive/5 rounded-md border border-destructive/20 overflow-x-auto">
|
||||
<pre className="text-[10px] text-destructive/80 whitespace-pre-wrap break-words font-mono max-h-[300px] overflow-y-auto">
|
||||
{entry.detail}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === 'success') {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs text-success bg-success/10 rounded-md px-2 py-1">
|
||||
<CheckCircle2 className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span className="break-words flex-1">{entry.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === 'info') {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs text-info bg-info/10 rounded-md px-2 py-1">
|
||||
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span className="break-words flex-1">{entry.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default text entry with source badge
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground py-0.5">
|
||||
<span className="text-[10px] text-muted-foreground/60 tabular-nums shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
{entry.source && (
|
||||
<Badge variant="outline" className={cn('text-[9px] px-1 py-0 shrink-0', getSourceColor(entry.source))}>
|
||||
{entry.source}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="break-words whitespace-pre-wrap flex-1">{entry.content}</span>
|
||||
{hasDetail && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded shrink-0',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors',
|
||||
isExpanded && 'bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
<span>Less</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-2.5 w-2.5" />
|
||||
<span>More</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasDetail && isExpanded && (
|
||||
<div className="mt-1.5 ml-12 p-2 bg-secondary/30 rounded-md border border-border/50 overflow-x-auto">
|
||||
<pre className="text-[10px] text-muted-foreground whitespace-pre-wrap break-words font-mono max-h-[300px] overflow-y-auto">
|
||||
{entry.detail}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AlertTriangle,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -47,7 +48,16 @@ export function ReviewFindings({
|
||||
new Set<SeverityGroup>(['critical', 'high']) // Critical and High expanded by default
|
||||
);
|
||||
|
||||
// Group findings by severity
|
||||
// Filter out posted findings - only show unposted findings for selection
|
||||
const unpostedFindings = useMemo(() =>
|
||||
findings.filter(f => !postedIds.has(f.id)),
|
||||
[findings, postedIds]
|
||||
);
|
||||
|
||||
// Check if all findings are posted
|
||||
const allFindingsPosted = findings.length > 0 && unpostedFindings.length === 0;
|
||||
|
||||
// Group unposted findings by severity (only show findings that haven't been posted)
|
||||
const groupedFindings = useMemo(() => {
|
||||
const groups: Record<SeverityGroup, PRReviewFinding[]> = {
|
||||
critical: [],
|
||||
@@ -56,7 +66,7 @@ export function ReviewFindings({
|
||||
low: [],
|
||||
};
|
||||
|
||||
for (const finding of findings) {
|
||||
for (const finding of unpostedFindings) {
|
||||
const severity = finding.severity as SeverityGroup;
|
||||
if (groups[severity]) {
|
||||
groups[severity].push(finding);
|
||||
@@ -64,19 +74,20 @@ export function ReviewFindings({
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [findings]);
|
||||
}, [unpostedFindings]);
|
||||
|
||||
// Count by severity
|
||||
// Count by severity (unposted findings only)
|
||||
const counts = useMemo(() => ({
|
||||
critical: groupedFindings.critical.length,
|
||||
high: groupedFindings.high.length,
|
||||
medium: groupedFindings.medium.length,
|
||||
low: groupedFindings.low.length,
|
||||
total: findings.length,
|
||||
total: unpostedFindings.length,
|
||||
important: groupedFindings.critical.length + groupedFindings.high.length,
|
||||
}), [groupedFindings, findings.length]);
|
||||
posted: postedIds.size,
|
||||
}), [groupedFindings, unpostedFindings.length, postedIds.size]);
|
||||
|
||||
// Selection hooks
|
||||
// Selection hooks - use unposted findings only
|
||||
const {
|
||||
toggleFinding,
|
||||
selectAll,
|
||||
@@ -84,7 +95,7 @@ export function ReviewFindings({
|
||||
selectImportant,
|
||||
toggleSeverityGroup,
|
||||
} = useFindingSelection({
|
||||
findings,
|
||||
findings: unpostedFindings,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
groupedFindings,
|
||||
@@ -103,11 +114,26 @@ export function ReviewFindings({
|
||||
});
|
||||
};
|
||||
|
||||
// When all findings have been posted, show a success message instead of the selection UI
|
||||
if (allFindingsPosted) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-8 text-muted-foreground bg-success/5 rounded-lg border border-success/20">
|
||||
<Send className="h-8 w-8 mx-auto mb-2 text-success" />
|
||||
<p className="text-sm font-medium text-success">{t('prReview.allFindingsPosted')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('prReview.findingsPostedCount', { count: counts.posted })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats Bar */}
|
||||
{/* Summary Stats Bar - show unposted findings only */}
|
||||
<FindingsSummary
|
||||
findings={findings}
|
||||
findings={unpostedFindings}
|
||||
selectedCount={selectedIds.size}
|
||||
/>
|
||||
|
||||
@@ -144,7 +170,7 @@ export function ReviewFindings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grouped Findings */}
|
||||
{/* Grouped Findings (unposted only) */}
|
||||
<div className="space-y-3">
|
||||
{SEVERITY_ORDER.map((severity) => {
|
||||
const group = groupedFindings[severity];
|
||||
@@ -183,7 +209,7 @@ export function ReviewFindings({
|
||||
key={finding.id}
|
||||
finding={finding}
|
||||
selected={selectedIds.has(finding.id)}
|
||||
posted={postedIds.has(finding.id)}
|
||||
posted={false}
|
||||
onToggle={() => toggleFinding(finding.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -194,7 +220,7 @@ export function ReviewFindings({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{/* Empty State - no findings at all */}
|
||||
{findings.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle className="h-8 w-8 mx-auto mb-2 text-success" />
|
||||
|
||||
@@ -164,8 +164,9 @@ export function ReviewStatusTree({
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Follow-up (only show when not currently reviewing)
|
||||
if (!isReviewing && newCommitsCheck?.hasNewCommits) {
|
||||
// Step 4: Follow-up (only show when not currently reviewing AND commits happened after posting)
|
||||
// This prevents showing follow-up prompts for commits that were made during/before the review
|
||||
if (!isReviewing && newCommitsCheck?.hasNewCommits && newCommitsCheck?.hasCommitsAfterPosting) {
|
||||
steps.push({
|
||||
id: 'new_commits',
|
||||
label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }),
|
||||
|
||||
@@ -104,18 +104,47 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
|
||||
setPrs(result);
|
||||
|
||||
// Preload review results for all PRs
|
||||
result.forEach(pr => {
|
||||
const preloadPromises = result.map(async (pr) => {
|
||||
const existingState = getPRReviewState(projectId, pr.number);
|
||||
// Only fetch from disk if we don't have a result in the store
|
||||
if (!existingState?.result) {
|
||||
window.electronAPI.github.getPRReview(projectId, pr.number).then(reviewResult => {
|
||||
if (reviewResult) {
|
||||
// Update store with the loaded result
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult);
|
||||
}
|
||||
});
|
||||
const reviewResult = await window.electronAPI.github.getPRReview(projectId, pr.number);
|
||||
if (reviewResult) {
|
||||
// Update store with the loaded result
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult);
|
||||
return { prNumber: pr.number, reviewResult };
|
||||
}
|
||||
} else {
|
||||
return { prNumber: pr.number, reviewResult: existingState.result };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Wait for all preloads to complete, then check for new commits
|
||||
const preloadResults = await Promise.all(preloadPromises);
|
||||
|
||||
// Check for new commits on PRs that have been reviewed
|
||||
// (either has reviewedCommitSha or the snake_case variant from older reviews)
|
||||
const prsWithReviews = preloadResults.filter(
|
||||
(r): r is { prNumber: number; reviewResult: PRReviewResult } =>
|
||||
r !== null &&
|
||||
(!!r.reviewResult?.reviewedCommitSha || !!(r.reviewResult as any)?.reviewed_commit_sha)
|
||||
);
|
||||
|
||||
if (prsWithReviews.length > 0) {
|
||||
// Check new commits in parallel for all reviewed PRs
|
||||
await Promise.all(
|
||||
prsWithReviews.map(async ({ prNumber }) => {
|
||||
try {
|
||||
const newCommitsResult = await window.electronAPI.github.checkNewCommits(projectId, prNumber);
|
||||
usePRReviewStore.getState().setNewCommitsCheck(projectId, prNumber, newCommitsResult);
|
||||
} catch (err) {
|
||||
// Silently fail for individual PR checks - don't block the list
|
||||
console.warn(`Failed to check new commits for PR #${prNumber}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -51,13 +51,17 @@ function getPRComputedStatus(
|
||||
|
||||
const result = reviewInfo.result;
|
||||
const hasPosted = Boolean(result.reviewId) || Boolean(result.hasPostedFindings);
|
||||
const hasBlockingFindings = result.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
// Use overallStatus from review result as source of truth, fallback to severity check
|
||||
const hasBlockingFindings =
|
||||
result.overallStatus === 'request_changes' ||
|
||||
result.findings?.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
const hasNewCommits = reviewInfo.newCommitsCheck?.hasNewCommits;
|
||||
// Only count commits that happened AFTER findings were posted for follow-up status
|
||||
const hasCommitsAfterPosting = reviewInfo.newCommitsCheck?.hasCommitsAfterPosting;
|
||||
|
||||
// Check for ready for follow-up first (highest priority after posting)
|
||||
if (hasPosted && hasNewCommits) {
|
||||
// Must have new commits that happened AFTER findings were posted
|
||||
if (hasPosted && hasNewCommits && hasCommitsAfterPosting) {
|
||||
return 'ready_for_followup';
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -2,6 +2,7 @@
|
||||
* SeverityGroupHeader - Collapsible header for a severity group with selection checkbox
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight, CheckSquare, Square, MinusSquare } from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { cn } from '../../../lib/utils';
|
||||
@@ -25,6 +26,7 @@ export function SeverityGroupHeader({
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
}: SeverityGroupHeaderProps) {
|
||||
const { t } = useTranslation(['gitlab', 'common']);
|
||||
const config = SEVERITY_CONFIG[severity];
|
||||
const Icon = config.icon;
|
||||
const isFullySelected = selectedCount === count && count > 0;
|
||||
@@ -53,13 +55,13 @@ export function SeverityGroupHeader({
|
||||
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<span className={cn("font-medium text-sm", config.color)}>
|
||||
{config.label}
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{config.description}
|
||||
{t(config.descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
|
||||
+10
-10
@@ -19,39 +19,39 @@ export type SeverityGroup = 'critical' | 'high' | 'medium' | 'low';
|
||||
export const SEVERITY_ORDER: SeverityGroup[] = ['critical', 'high', 'medium', 'low'];
|
||||
|
||||
export const SEVERITY_CONFIG: Record<SeverityGroup, {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: typeof XCircle;
|
||||
description: string;
|
||||
descriptionKey: string;
|
||||
}> = {
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
labelKey: 'mrReview.severity.critical',
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10 border-red-500/30',
|
||||
icon: XCircle,
|
||||
description: 'Must fix before merge',
|
||||
descriptionKey: 'mrReview.severity.criticalDesc',
|
||||
},
|
||||
high: {
|
||||
label: 'High',
|
||||
labelKey: 'mrReview.severity.high',
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-500/10 border-orange-500/30',
|
||||
icon: AlertTriangle,
|
||||
description: 'Should fix before merge',
|
||||
descriptionKey: 'mrReview.severity.highDesc',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
labelKey: 'mrReview.severity.medium',
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-500/10 border-yellow-500/30',
|
||||
icon: AlertCircle,
|
||||
description: 'Consider fixing',
|
||||
descriptionKey: 'mrReview.severity.mediumDesc',
|
||||
},
|
||||
low: {
|
||||
label: 'Low',
|
||||
labelKey: 'mrReview.severity.low',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/30',
|
||||
icon: CheckCircle,
|
||||
description: 'Nice to have',
|
||||
descriptionKey: 'mrReview.severity.lowDesc',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -96,7 +96,10 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => onDismiss(idea)}
|
||||
onClick={() => {
|
||||
onDismiss(idea);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Dismiss Idea
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Terminal, Loader2, Check, AlertTriangle, X, RefreshCw, Download, Info, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import type { ClaudeCodeVersionInfo } from '../../../shared/types/cli';
|
||||
|
||||
interface ClaudeCodeStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
type DetectionStatus = 'loading' | 'installed' | 'outdated' | 'not-found' | 'error';
|
||||
|
||||
/**
|
||||
* Claude Code CLI installation step for the onboarding wizard.
|
||||
*
|
||||
* Checks if Claude Code CLI is installed, shows version information,
|
||||
* and provides one-click installation/update functionality.
|
||||
*/
|
||||
export function ClaudeCodeStep({ onNext, onBack, onSkip }: ClaudeCodeStepProps) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const [status, setStatus] = useState<DetectionStatus>('loading');
|
||||
const [versionInfo, setVersionInfo] = useState<ClaudeCodeVersionInfo | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installSuccess, setInstallSuccess] = useState(false);
|
||||
|
||||
// Check Claude Code version on mount
|
||||
const checkVersion = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
setInstallSuccess(false);
|
||||
|
||||
try {
|
||||
if (!window.electronAPI?.checkClaudeCodeVersion) {
|
||||
console.warn('[ClaudeCodeStep] Version check API not available');
|
||||
setStatus('error');
|
||||
setError('Version check API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.checkClaudeCodeVersion();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setVersionInfo(result.data);
|
||||
|
||||
if (!result.data.installed) {
|
||||
setStatus('not-found');
|
||||
} else if (result.data.isOutdated) {
|
||||
setStatus('outdated');
|
||||
} else {
|
||||
setStatus('installed');
|
||||
}
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.error || 'Failed to check version');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check Claude Code version:', err);
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkVersion();
|
||||
}, [checkVersion]);
|
||||
|
||||
// Handle install/update button click
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!window.electronAPI?.installClaudeCode) {
|
||||
setError('Install API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.installClaudeCode();
|
||||
|
||||
if (result.success) {
|
||||
setInstallSuccess(true);
|
||||
// Re-check version after a short delay to give user time to complete installation
|
||||
setTimeout(() => {
|
||||
checkVersion();
|
||||
}, 5000);
|
||||
} else {
|
||||
setError(result.error || 'Failed to start installation');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />;
|
||||
case 'installed':
|
||||
return <Check className="h-6 w-6 text-green-500" />;
|
||||
case 'outdated':
|
||||
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||
case 'not-found':
|
||||
return <X className="h-6 w-6 text-destructive" />;
|
||||
case 'error':
|
||||
return <AlertTriangle className="h-6 w-6 text-destructive" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status text
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return t('claudeCode.detecting', 'Checking Claude Code installation...');
|
||||
case 'installed':
|
||||
return t('claudeCode.status.installed', 'Installed');
|
||||
case 'outdated':
|
||||
return t('claudeCode.status.outdated', 'Update Available');
|
||||
case 'not-found':
|
||||
return t('claudeCode.status.notFound', 'Not Installed');
|
||||
case 'error':
|
||||
return error || 'Error checking status';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color class
|
||||
const getStatusColorClass = () => {
|
||||
switch (status) {
|
||||
case 'installed':
|
||||
return 'text-green-500';
|
||||
case 'outdated':
|
||||
return 'text-yellow-500';
|
||||
case 'not-found':
|
||||
case 'error':
|
||||
return 'text-destructive';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Terminal className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">
|
||||
{t('claudeCode.title', 'Claude Code CLI')}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t('claudeCode.description', 'Install or update the Claude Code CLI to enable AI-powered features')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="space-y-6">
|
||||
{/* Info card */}
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('claudeCode.info.title', 'What is Claude Code?')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('claudeCode.info.description', "Claude Code is Anthropic's official CLI that powers Auto Claude's AI features. It provides secure authentication and direct access to Claude models.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status card */}
|
||||
<Card className={`border ${status === 'installed' ? 'border-green-500/30 bg-green-500/5' : status === 'outdated' ? 'border-yellow-500/30 bg-yellow-500/5' : status === 'not-found' || status === 'error' ? 'border-destructive/30 bg-destructive/5' : 'border-border'}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon()}
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${getStatusColorClass()}`}>
|
||||
{getStatusText()}
|
||||
</p>
|
||||
{versionInfo && status !== 'loading' && (
|
||||
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
|
||||
{versionInfo.installed && (
|
||||
<p>
|
||||
{t('claudeCode.version.current', 'Current Version')}: <span className="font-mono">{versionInfo.installed}</span>
|
||||
</p>
|
||||
)}
|
||||
{versionInfo.latest && versionInfo.latest !== 'unknown' && (
|
||||
<p>
|
||||
{t('claudeCode.version.latest', 'Latest Version')}: <span className="font-mono">{versionInfo.latest}</span>
|
||||
</p>
|
||||
)}
|
||||
{versionInfo.path && (
|
||||
<p className="truncate max-w-md" title={versionInfo.path}>
|
||||
Path: <span className="font-mono">{versionInfo.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkVersion}
|
||||
disabled={status === 'loading' || isInstalling}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${status === 'loading' ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error message */}
|
||||
{error && status !== 'loading' && (
|
||||
<Card className="border border-destructive/30 bg-destructive/10">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Install success message */}
|
||||
{installSuccess && (
|
||||
<Card className="border border-green-500/30 bg-green-500/10">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('claudeCode.install.success', 'Installation command sent to terminal. Please complete the installation there.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('claudeCode.install.instructions', 'The installer will open in your terminal. Follow the prompts to complete installation.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Install/Update button */}
|
||||
{(status === 'not-found' || status === 'outdated') && !installSuccess && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('claudeCode.install.inProgress', 'Installing...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
{status === 'outdated'
|
||||
? t('claudeCode.install.updating', 'Update Claude Code')
|
||||
: t('claudeCode.install.button', 'Install Claude Code')
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation link */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground gap-1"
|
||||
onClick={() => window.electronAPI?.openExternal?.('https://claude.ai/code')}
|
||||
>
|
||||
{t('claudeCode.learnMore', 'Learn more about Claude Code')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-border">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
{t('common:back', 'Back')}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="ghost" onClick={onSkip}>
|
||||
{t('common:skip', 'Skip')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
{status === 'installed'
|
||||
? t('common:continue', 'Continue')
|
||||
: t('common:continueAnyway', 'Continue Anyway')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ScrollArea } from '../ui/scroll-area';
|
||||
import { WizardProgress, WizardStep } from './WizardProgress';
|
||||
import { WelcomeStep } from './WelcomeStep';
|
||||
import { OAuthStep } from './OAuthStep';
|
||||
import { ClaudeCodeStep } from './ClaudeCodeStep';
|
||||
import { DevToolsStep } from './DevToolsStep';
|
||||
import { MemoryStep } from './MemoryStep';
|
||||
import { CompletionStep } from './CompletionStep';
|
||||
@@ -26,12 +27,13 @@ interface OnboardingWizardProps {
|
||||
}
|
||||
|
||||
// Wizard step identifiers
|
||||
type WizardStepId = 'welcome' | 'oauth' | 'devtools' | 'memory' | 'completion';
|
||||
type WizardStepId = 'welcome' | 'oauth' | 'claude-code' | 'devtools' | 'memory' | 'completion';
|
||||
|
||||
// Step configuration with translation keys
|
||||
const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [
|
||||
{ id: 'welcome', labelKey: 'steps.welcome' },
|
||||
{ id: 'oauth', labelKey: 'steps.auth' },
|
||||
{ id: 'claude-code', labelKey: 'steps.claudeCode' },
|
||||
{ id: 'devtools', labelKey: 'steps.devtools' },
|
||||
{ id: 'memory', labelKey: 'steps.memory' },
|
||||
{ id: 'completion', labelKey: 'steps.done' }
|
||||
@@ -157,6 +159,14 @@ export function OnboardingWizard({
|
||||
onSkip={skipWizard}
|
||||
/>
|
||||
);
|
||||
case 'claude-code':
|
||||
return (
|
||||
<ClaudeCodeStep
|
||||
onNext={goToNextStep}
|
||||
onBack={goToPreviousStep}
|
||||
onSkip={skipWizard}
|
||||
/>
|
||||
);
|
||||
case 'devtools':
|
||||
return (
|
||||
<DevToolsStep
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch, Server } from 'lucide-react';
|
||||
import { RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch, Server, Terminal, ExternalLink } from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Switch } from '../../ui/switch';
|
||||
@@ -63,6 +63,13 @@ export function GitLabIntegration({
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
|
||||
// glab CLI detection state
|
||||
const [glabInstalled, setGlabInstalled] = useState<boolean | null>(null);
|
||||
const [glabVersion, setGlabVersion] = useState<string | null>(null);
|
||||
const [isCheckingGlab, setIsCheckingGlab] = useState(false);
|
||||
const [isInstallingGlab, setIsInstallingGlab] = useState(false);
|
||||
const [glabInstallSuccess, setGlabInstallSuccess] = useState(false);
|
||||
|
||||
debugLog('Render - authMode:', authMode);
|
||||
debugLog('Render - projectPath:', projectPath);
|
||||
debugLog('Render - envConfig:', envConfig ? { gitlabEnabled: envConfig.gitlabEnabled, hasToken: !!envConfig.gitlabToken, defaultBranch: envConfig.defaultBranch } : null);
|
||||
@@ -74,6 +81,29 @@ export function GitLabIntegration({
|
||||
}
|
||||
}, [authMode]);
|
||||
|
||||
// Check glab CLI on mount
|
||||
useEffect(() => {
|
||||
const checkGlab = async () => {
|
||||
setIsCheckingGlab(true);
|
||||
try {
|
||||
const result = await window.electronAPI.checkGitLabCli();
|
||||
debugLog('checkGitLabCli result:', result);
|
||||
if (result.success && result.data) {
|
||||
setGlabInstalled(result.data.installed);
|
||||
setGlabVersion(result.data.version || null);
|
||||
} else {
|
||||
setGlabInstalled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('Error checking glab CLI:', error);
|
||||
setGlabInstalled(false);
|
||||
} finally {
|
||||
setIsCheckingGlab(false);
|
||||
}
|
||||
};
|
||||
checkGlab();
|
||||
}, []);
|
||||
|
||||
// Fetch branches when GitLab is enabled and project path is available
|
||||
useEffect(() => {
|
||||
debugLog(`useEffect[branches] - gitlabEnabled: ${envConfig?.gitlabEnabled}, projectPath: ${projectPath}`);
|
||||
@@ -211,6 +241,48 @@ export function GitLabIntegration({
|
||||
updateEnvConfig({ gitlabProject: projectPath });
|
||||
};
|
||||
|
||||
const handleInstallGlab = async () => {
|
||||
setIsInstallingGlab(true);
|
||||
setGlabInstallSuccess(false);
|
||||
try {
|
||||
const result = await window.electronAPI.installGitLabCli();
|
||||
debugLog('installGitLabCli result:', result);
|
||||
if (result.success) {
|
||||
setGlabInstallSuccess(true);
|
||||
// Re-check after 5 seconds to give user time to complete installation
|
||||
setTimeout(async () => {
|
||||
await handleRefreshGlab();
|
||||
setIsInstallingGlab(false);
|
||||
}, 5000);
|
||||
} else {
|
||||
setIsInstallingGlab(false);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('Error installing glab:', error);
|
||||
setIsInstallingGlab(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshGlab = async () => {
|
||||
setIsCheckingGlab(true);
|
||||
setGlabInstallSuccess(false);
|
||||
try {
|
||||
const result = await window.electronAPI.checkGitLabCli();
|
||||
debugLog('checkGitLabCli refresh result:', result);
|
||||
if (result.success && result.data) {
|
||||
setGlabInstalled(result.data.installed);
|
||||
setGlabVersion(result.data.version || null);
|
||||
} else {
|
||||
setGlabInstalled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('Error refreshing glab status:', error);
|
||||
setGlabInstalled(false);
|
||||
} finally {
|
||||
setIsCheckingGlab(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -305,6 +377,86 @@ export function GitLabIntegration({
|
||||
{/* Manual Token Entry */}
|
||||
{authMode === 'manual' && (
|
||||
<>
|
||||
{/* glab CLI Required Card */}
|
||||
{glabInstalled === false && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5 shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.cli.required')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('settings.cli.notInstalled')}
|
||||
</p>
|
||||
</div>
|
||||
{glabInstallSuccess ? (
|
||||
<div className="rounded-md border border-success/30 bg-success/10 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<p className="text-xs text-success">{t('settings.cli.installSuccess')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshGlab}
|
||||
disabled={isCheckingGlab}
|
||||
className="h-7 gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isCheckingGlab ? 'animate-spin' : ''}`} />
|
||||
{t('settings.cli.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleInstallGlab}
|
||||
disabled={isInstallingGlab}
|
||||
className="gap-2"
|
||||
>
|
||||
{isInstallingGlab ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t('settings.cli.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="h-3 w-3" />
|
||||
{t('settings.cli.installButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<a
|
||||
href="https://gitlab.com/gitlab-org/cli#installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-info hover:underline flex items-center gap-1"
|
||||
>
|
||||
{t('settings.cli.learnMore')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* glab CLI Installed Success */}
|
||||
{glabInstalled === true && glabVersion && (
|
||||
<div className="rounded-lg border border-success/30 bg-success/10 p-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<p className="text-xs text-success">
|
||||
{t('settings.cli.installed')} <span className="font-mono">{glabVersion}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-foreground">{t('settings.personalAccessToken')}</Label>
|
||||
@@ -312,9 +464,14 @@ export function GitLabIntegration({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSwitchToOAuth}
|
||||
disabled={glabInstalled === false || isCheckingGlab}
|
||||
className="gap-2"
|
||||
>
|
||||
<KeyRound className="h-3 w-3" />
|
||||
{isCheckingGlab ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="h-3 w-3" />
|
||||
)}
|
||||
{t('settings.useOAuth')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -156,6 +156,7 @@ const browserMockAPI: ElectronAPI = {
|
||||
deletePRReview: async () => true,
|
||||
checkNewCommits: async () => ({ hasNewCommits: false, newCommitCount: 0 }),
|
||||
runFollowupReview: () => {},
|
||||
getPRLogs: async () => null,
|
||||
onPRReviewProgress: () => () => {},
|
||||
onPRReviewComplete: () => () => {},
|
||||
onPRReviewError: () => () => {},
|
||||
@@ -172,6 +173,47 @@ const browserMockAPI: ElectronAPI = {
|
||||
onAnalyzePreviewError: () => () => {}
|
||||
},
|
||||
|
||||
// Claude Code Operations
|
||||
checkClaudeCodeVersion: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
installed: '1.0.0',
|
||||
latest: '1.0.0',
|
||||
isOutdated: false,
|
||||
path: '/usr/local/bin/claude',
|
||||
detectionResult: {
|
||||
found: true,
|
||||
version: '1.0.0',
|
||||
path: '/usr/local/bin/claude',
|
||||
source: 'system-path' as const,
|
||||
message: 'Claude Code CLI found'
|
||||
}
|
||||
}
|
||||
}),
|
||||
installClaudeCode: async () => ({
|
||||
success: true,
|
||||
data: { command: 'npm install -g @anthropic-ai/claude-code' }
|
||||
}),
|
||||
|
||||
// MCP Server Health Check Operations
|
||||
checkMcpHealth: async (server) => ({
|
||||
success: true,
|
||||
data: {
|
||||
serverId: server.id,
|
||||
status: 'unknown' as const,
|
||||
message: 'Health check not available in browser mode',
|
||||
checkedAt: new Date().toISOString()
|
||||
}
|
||||
}),
|
||||
testMcpConnection: async (server) => ({
|
||||
success: true,
|
||||
data: {
|
||||
serverId: server.id,
|
||||
success: false,
|
||||
message: 'Connection test not available in browser mode'
|
||||
}
|
||||
}),
|
||||
|
||||
// Debug Operations
|
||||
getDebugInfo: async () => ({
|
||||
systemInfo: {
|
||||
|
||||
@@ -312,6 +312,11 @@ export const integrationMock = {
|
||||
}
|
||||
}),
|
||||
|
||||
installGitLabCli: async () => ({
|
||||
success: false,
|
||||
error: 'Not available in browser mock'
|
||||
}),
|
||||
|
||||
checkGitLabAuth: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type IdeationTypeState = 'pending' | 'generating' | 'completed' | 'failed
|
||||
|
||||
interface IdeationState {
|
||||
// Data
|
||||
currentProjectId: string | null;
|
||||
session: IdeationSession | null;
|
||||
generationStatus: IdeationGenerationStatus;
|
||||
config: IdeationConfig;
|
||||
@@ -35,6 +36,7 @@ interface IdeationState {
|
||||
isGenerating: boolean;
|
||||
|
||||
// Actions
|
||||
setCurrentProjectId: (projectId: string | null) => void;
|
||||
setSession: (session: IdeationSession | null) => void;
|
||||
setIsGenerating: (isGenerating: boolean) => void;
|
||||
setGenerationStatus: (status: IdeationGenerationStatus) => void;
|
||||
@@ -86,6 +88,7 @@ const initialTypeStates: Record<IdeationType, IdeationTypeState> = {
|
||||
|
||||
export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
// Initial state
|
||||
currentProjectId: null,
|
||||
session: null,
|
||||
generationStatus: initialGenerationStatus,
|
||||
config: initialConfig,
|
||||
@@ -95,6 +98,23 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
isGenerating: false,
|
||||
|
||||
// Actions
|
||||
setCurrentProjectId: (projectId) =>
|
||||
set((state) => {
|
||||
// If switching to a different project, clear the state
|
||||
if (state.currentProjectId !== projectId) {
|
||||
return {
|
||||
currentProjectId: projectId,
|
||||
session: null,
|
||||
generationStatus: initialGenerationStatus,
|
||||
logs: [],
|
||||
typeStates: { ...initialTypeStates },
|
||||
selectedIds: new Set<string>(),
|
||||
isGenerating: false
|
||||
};
|
||||
}
|
||||
return { currentProjectId: projectId };
|
||||
}),
|
||||
|
||||
setSession: (session) => set({ session }),
|
||||
|
||||
setIsGenerating: (isGenerating) => set({ isGenerating }),
|
||||
@@ -349,21 +369,28 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
}));
|
||||
|
||||
export async function loadIdeation(projectId: string): Promise<void> {
|
||||
if (useIdeationStore.getState().isGenerating) {
|
||||
const store = useIdeationStore.getState();
|
||||
|
||||
// Set the current project ID (this clears state if switching projects)
|
||||
store.setCurrentProjectId(projectId);
|
||||
|
||||
if (store.isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getIdeation(projectId);
|
||||
|
||||
// Check again after async operation to handle race condition
|
||||
if (useIdeationStore.getState().isGenerating) {
|
||||
const currentState = useIdeationStore.getState();
|
||||
if (currentState.isGenerating || currentState.currentProjectId !== projectId) {
|
||||
// Project changed during async operation, ignore result
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
useIdeationStore.getState().setSession(result.data);
|
||||
currentState.setSession(result.data);
|
||||
} else {
|
||||
useIdeationStore.getState().setSession(null);
|
||||
currentState.setSession(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,12 +641,26 @@ export function isUIUXIdea(idea: Idea): idea is Idea & { type: 'ui_ux_improvemen
|
||||
export function setupIdeationListeners(): () => void {
|
||||
const store = useIdeationStore.getState;
|
||||
|
||||
// Helper to check if event is for the current project
|
||||
const isCurrentProject = (eventProjectId: string): boolean => {
|
||||
const currentProjectId = store().currentProjectId;
|
||||
return currentProjectId === eventProjectId;
|
||||
};
|
||||
|
||||
// Listen for progress updates
|
||||
const unsubProgress = window.electronAPI.onIdeationProgress((_projectId, status) => {
|
||||
const unsubProgress = window.electronAPI.onIdeationProgress((projectId, status) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) {
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Ignoring progress for different project:', projectId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Progress update:', {
|
||||
projectId: _projectId,
|
||||
projectId,
|
||||
phase: status.phase,
|
||||
progress: status.progress,
|
||||
message: status.message
|
||||
@@ -629,17 +670,26 @@ export function setupIdeationListeners(): () => void {
|
||||
});
|
||||
|
||||
// Listen for log messages
|
||||
const unsubLog = window.electronAPI.onIdeationLog((_projectId, log) => {
|
||||
const unsubLog = window.electronAPI.onIdeationLog((projectId, log) => {
|
||||
if (!isCurrentProject(projectId)) return;
|
||||
store().addLog(log);
|
||||
});
|
||||
|
||||
// Listen for individual ideation type completion (streaming)
|
||||
const unsubTypeComplete = window.electronAPI.onIdeationTypeComplete(
|
||||
(_projectId, ideationType, ideas) => {
|
||||
(projectId, ideationType, ideas) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) {
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Ignoring type complete for different project:', projectId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Type completed:', {
|
||||
projectId: _projectId,
|
||||
projectId,
|
||||
ideationType,
|
||||
ideasCount: ideas.length,
|
||||
ideas: ideas.map(i => ({ id: i.id, title: i.title, type: i.type }))
|
||||
@@ -677,10 +727,13 @@ export function setupIdeationListeners(): () => void {
|
||||
|
||||
// Listen for individual ideation type failure
|
||||
const unsubTypeFailed = window.electronAPI.onIdeationTypeFailed(
|
||||
(_projectId, ideationType) => {
|
||||
(projectId, ideationType) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) return;
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Ideation] Type failed:', { projectId: _projectId, ideationType });
|
||||
console.error('[Ideation] Type failed:', { projectId, ideationType });
|
||||
}
|
||||
|
||||
store().setTypeState(ideationType as IdeationType, 'failed');
|
||||
@@ -688,10 +741,18 @@ export function setupIdeationListeners(): () => void {
|
||||
}
|
||||
);
|
||||
|
||||
const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => {
|
||||
const unsubComplete = window.electronAPI.onIdeationComplete((projectId, session) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) {
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Ignoring complete for different project:', projectId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Generation complete:', {
|
||||
projectId: _projectId,
|
||||
projectId,
|
||||
totalIdeas: session.ideas.length,
|
||||
ideaTypes: session.ideas.reduce((acc, idea) => {
|
||||
acc[idea.type] = (acc[idea.type] || 0) + 1;
|
||||
@@ -700,7 +761,7 @@ export function setupIdeationListeners(): () => void {
|
||||
});
|
||||
}
|
||||
|
||||
clearGenerationTimeout(_projectId);
|
||||
clearGenerationTimeout(projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().setSession(session);
|
||||
@@ -713,12 +774,15 @@ export function setupIdeationListeners(): () => void {
|
||||
store().addLog('Ideation generation complete!');
|
||||
});
|
||||
|
||||
const unsubError = window.electronAPI.onIdeationError((_projectId, error) => {
|
||||
const unsubError = window.electronAPI.onIdeationError((projectId, error) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) return;
|
||||
|
||||
if (window.DEBUG) {
|
||||
console.error('[Ideation] Error received:', { projectId: _projectId, error });
|
||||
console.error('[Ideation] Error received:', { projectId, error });
|
||||
}
|
||||
|
||||
clearGenerationTimeout(_projectId);
|
||||
clearGenerationTimeout(projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().resetGeneratingTypes('failed');
|
||||
@@ -731,12 +795,15 @@ export function setupIdeationListeners(): () => void {
|
||||
store().addLog(`Error: ${error}`);
|
||||
});
|
||||
|
||||
const unsubStopped = window.electronAPI.onIdeationStopped((_projectId) => {
|
||||
const unsubStopped = window.electronAPI.onIdeationStopped((projectId) => {
|
||||
// Only process events for the current project
|
||||
if (!isCurrentProject(projectId)) return;
|
||||
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Stopped:', { projectId: _projectId });
|
||||
console.log('[Ideation] Stopped:', { projectId });
|
||||
}
|
||||
|
||||
clearGenerationTimeout(_projectId);
|
||||
clearGenerationTimeout(projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().resetGeneratingTypes('pending');
|
||||
|
||||
@@ -231,6 +231,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// GitLab OAuth (glab CLI authentication)
|
||||
GITLAB_CHECK_CLI: 'gitlab:checkCli',
|
||||
GITLAB_INSTALL_CLI: 'gitlab:installCli',
|
||||
GITLAB_CHECK_AUTH: 'gitlab:checkAuth',
|
||||
GITLAB_START_AUTH: 'gitlab:startAuth',
|
||||
GITLAB_GET_TOKEN: 'gitlab:getToken',
|
||||
@@ -350,6 +351,9 @@ export const IPC_CHANNELS = {
|
||||
GITHUB_PR_REVIEW_COMPLETE: 'github:pr:reviewComplete',
|
||||
GITHUB_PR_REVIEW_ERROR: 'github:pr:reviewError',
|
||||
|
||||
// GitHub PR Logs (for viewing AI review logs)
|
||||
GITHUB_PR_GET_LOGS: 'github:pr:getLogs',
|
||||
|
||||
// GitHub Issue Triage operations
|
||||
GITHUB_TRIAGE_RUN: 'github:triage:run',
|
||||
GITHUB_TRIAGE_GET_RESULTS: 'github:triage:getResults',
|
||||
@@ -464,5 +468,13 @@ export const IPC_CHANNELS = {
|
||||
DEBUG_OPEN_LOGS_FOLDER: 'debug:openLogsFolder',
|
||||
DEBUG_COPY_DEBUG_INFO: 'debug:copyDebugInfo',
|
||||
DEBUG_GET_RECENT_ERRORS: 'debug:getRecentErrors',
|
||||
DEBUG_LIST_LOG_FILES: 'debug:listLogFiles'
|
||||
DEBUG_LIST_LOG_FILES: 'debug:listLogFiles',
|
||||
|
||||
// Claude Code CLI operations
|
||||
CLAUDE_CODE_CHECK_VERSION: 'claudeCode:checkVersion',
|
||||
CLAUDE_CODE_INSTALL: 'claudeCode:install',
|
||||
|
||||
// MCP Server health checks
|
||||
MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check
|
||||
MCP_TEST_CONNECTION: 'mcp:testConnection' // Full MCP protocol test
|
||||
} as const;
|
||||
|
||||
@@ -69,13 +69,14 @@ export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingCo
|
||||
// Feature Settings (Non-Pipeline Features)
|
||||
// ============================================
|
||||
|
||||
// Default feature model configuration (for insights, ideation, roadmap, github)
|
||||
// Default feature model configuration (for insights, ideation, roadmap, github, utility)
|
||||
export const DEFAULT_FEATURE_MODELS: FeatureModelConfig = {
|
||||
insights: 'sonnet', // Fast, responsive chat
|
||||
ideation: 'opus', // Creative ideation benefits from Opus
|
||||
roadmap: 'opus', // Strategic planning benefits from Opus
|
||||
githubIssues: 'opus', // Issue triage and analysis benefits from Opus
|
||||
githubPrs: 'opus' // PR review benefits from thorough Opus analysis
|
||||
githubPrs: 'opus', // PR review benefits from thorough Opus analysis
|
||||
utility: 'haiku' // Fast utility operations (commit messages, merge resolution)
|
||||
};
|
||||
|
||||
// Default feature thinking configuration
|
||||
@@ -84,7 +85,8 @@ export const DEFAULT_FEATURE_THINKING: FeatureThinkingConfig = {
|
||||
ideation: 'high', // Deep thinking for creative ideas
|
||||
roadmap: 'high', // Strategic thinking for roadmap
|
||||
githubIssues: 'medium', // Moderate thinking for issue analysis
|
||||
githubPrs: 'medium' // Moderate thinking for PR review
|
||||
githubPrs: 'medium', // Moderate thinking for PR review
|
||||
utility: 'low' // Fast thinking for utility operations
|
||||
};
|
||||
|
||||
// Feature labels for UI display
|
||||
@@ -93,7 +95,8 @@ export const FEATURE_LABELS: Record<keyof FeatureModelConfig, { label: string; d
|
||||
ideation: { label: 'Ideation', description: 'Generate feature ideas and improvements' },
|
||||
roadmap: { label: 'Roadmap', description: 'Create strategic feature roadmaps' },
|
||||
githubIssues: { label: 'GitHub Issues', description: 'Automated issue triage and labeling' },
|
||||
githubPrs: { label: 'GitHub PR Review', description: 'AI-powered pull request reviews' }
|
||||
githubPrs: { label: 'GitHub PR Review', description: 'AI-powered pull request reviews' },
|
||||
utility: { label: 'Utility', description: 'Commit messages and merge conflict resolution' }
|
||||
};
|
||||
|
||||
// Default agent profiles for preset model/thinking configurations
|
||||
|
||||
@@ -169,14 +169,14 @@
|
||||
"findingsPosted": "{{count}} Posted",
|
||||
"followupInProgress": "Follow-up Analysis in Progress...",
|
||||
"severity": {
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low",
|
||||
"criticalDesc": "Must fix before merge",
|
||||
"highDesc": "Should fix before merge",
|
||||
"mediumDesc": "Consider fixing",
|
||||
"lowDesc": "Nice to have"
|
||||
"critical": "Blocker",
|
||||
"high": "Required",
|
||||
"medium": "Recommended",
|
||||
"low": "Suggestion",
|
||||
"criticalDesc": "Must fix",
|
||||
"highDesc": "Should fix",
|
||||
"mediumDesc": "Improve quality",
|
||||
"lowDesc": "Consider"
|
||||
},
|
||||
"category": {
|
||||
"security": "Security",
|
||||
@@ -193,10 +193,13 @@
|
||||
"closed": "Closed",
|
||||
"merged": "Merged"
|
||||
},
|
||||
"selectCriticalHigh": "Select Critical/High ({{count}})",
|
||||
"selectCriticalHigh": "Select Blocker/Required ({{count}})",
|
||||
"selectAll": "Select All",
|
||||
"clear": "Clear",
|
||||
"noIssuesFound": "No issues found! The code looks good.",
|
||||
"allFindingsPosted": "All findings posted to GitHub",
|
||||
"findingsPostedCount": "{{count}} finding posted to GitHub",
|
||||
"findingsPostedCount_plural": "{{count}} findings posted to GitHub",
|
||||
"selectedOfTotal": "{{selected}}/{{total}} selected",
|
||||
"suggestedFix": "Suggested fix:",
|
||||
"runAIReviewDesc": "Run an AI review to analyze this PR",
|
||||
@@ -220,7 +223,10 @@
|
||||
"findingsNeedPosting": "{{count}} finding need to be posted to GitHub.",
|
||||
"findingsNeedPosting_plural": "{{count}} findings need to be posted to GitHub.",
|
||||
"findingsFoundSelectPost": "{{count}} finding found. Select and post to GitHub.",
|
||||
"findingsFoundSelectPost_plural": "{{count}} findings found. Select and post to GitHub."
|
||||
"findingsFoundSelectPost_plural": "{{count}} findings found. Select and post to GitHub.",
|
||||
"reviewLogs": "Review Logs",
|
||||
"followup": "Follow-up",
|
||||
"initial": "Initial"
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Toggle download details",
|
||||
|
||||
@@ -100,7 +100,17 @@
|
||||
"noBranchesFound": "No branches found",
|
||||
"branchFromNote": "All new tasks will branch from",
|
||||
"autoSyncOnLoad": "Auto-Sync on Load",
|
||||
"autoSyncDescription": "Automatically fetch issues when the project loads"
|
||||
"autoSyncDescription": "Automatically fetch issues when the project loads",
|
||||
"cli": {
|
||||
"required": "GitLab CLI Required",
|
||||
"notInstalled": "The GitLab CLI (glab) is required for OAuth authentication. Install it to use the 'Use OAuth' option.",
|
||||
"installButton": "Install glab",
|
||||
"installing": "Installing...",
|
||||
"installSuccess": "Installation started in your terminal. Complete it and click Refresh.",
|
||||
"refresh": "Refresh",
|
||||
"learnMore": "Learn more",
|
||||
"installed": "GitLab CLI installed:"
|
||||
}
|
||||
},
|
||||
"mergeRequests": {
|
||||
"title": "GitLab Merge Requests",
|
||||
@@ -168,21 +178,21 @@
|
||||
},
|
||||
"findings": {
|
||||
"summary": "selected",
|
||||
"selectCriticalHigh": "Select Critical/High",
|
||||
"selectCriticalHigh": "Select Blocker/Required",
|
||||
"selectAll": "Select All",
|
||||
"clear": "Clear",
|
||||
"noIssues": "No issues found! The code looks good.",
|
||||
"suggestedFix": "Suggested fix:",
|
||||
"posted": "Posted",
|
||||
"severity": {
|
||||
"critical": "Critical",
|
||||
"criticalDesc": "Must fix before merge",
|
||||
"high": "High",
|
||||
"highDesc": "Should fix before merge",
|
||||
"medium": "Medium",
|
||||
"mediumDesc": "Consider fixing",
|
||||
"low": "Low",
|
||||
"lowDesc": "Nice to have"
|
||||
"critical": "Blocker",
|
||||
"criticalDesc": "Must fix",
|
||||
"high": "Required",
|
||||
"highDesc": "Should fix",
|
||||
"medium": "Recommended",
|
||||
"mediumDesc": "Improve quality",
|
||||
"low": "Suggestion",
|
||||
"lowDesc": "Consider"
|
||||
},
|
||||
"category": {
|
||||
"security": "Security",
|
||||
|
||||
@@ -29,5 +29,22 @@
|
||||
},
|
||||
"messages": {
|
||||
"initializeToCreateTasks": "Initialize Auto Claude to create tasks"
|
||||
},
|
||||
"claudeCode": {
|
||||
"checking": "Checking Claude Code...",
|
||||
"upToDate": "Claude Code is up to date",
|
||||
"updateAvailable": "Claude Code update available",
|
||||
"notInstalled": "Claude Code not installed",
|
||||
"error": "Error checking Claude Code",
|
||||
"installed": "Installed",
|
||||
"outdated": "Update available",
|
||||
"missing": "Not installed",
|
||||
"current": "Current",
|
||||
"latest": "Latest",
|
||||
"lastChecked": "Last checked",
|
||||
"learnMore": "Learn more about Claude Code",
|
||||
"updateWarningTitle": "Update Claude Code?",
|
||||
"updateWarningDescription": "Updating will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding.",
|
||||
"updateAnyway": "Update Anyway"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,37 @@
|
||||
"steps": {
|
||||
"welcome": "Welcome",
|
||||
"auth": "Auth",
|
||||
"claudeCode": "CLI",
|
||||
"devtools": "Dev Tools",
|
||||
"memory": "Memory",
|
||||
"done": "Done"
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code CLI",
|
||||
"description": "Install or update the Claude Code CLI to enable AI-powered features",
|
||||
"detecting": "Checking Claude Code installation...",
|
||||
"info": {
|
||||
"title": "What is Claude Code?",
|
||||
"description": "Claude Code is Anthropic's official CLI that powers Auto Claude's AI features. It provides secure authentication and direct access to Claude models."
|
||||
},
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"outdated": "Update Available",
|
||||
"notFound": "Not Installed"
|
||||
},
|
||||
"version": {
|
||||
"current": "Current Version",
|
||||
"latest": "Latest Version"
|
||||
},
|
||||
"install": {
|
||||
"button": "Install Claude Code",
|
||||
"updating": "Update Claude Code",
|
||||
"inProgress": "Installing...",
|
||||
"success": "Installation command sent to terminal. Please complete the installation there.",
|
||||
"instructions": "The installer will open in your terminal. Follow the prompts to complete installation."
|
||||
},
|
||||
"learnMore": "Learn more about Claude Code"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Developer Tools",
|
||||
"description": "Choose your preferred IDE and terminal for working with Auto Claude worktrees",
|
||||
|
||||
@@ -318,5 +318,104 @@
|
||||
"noRecentErrors": "No recent errors",
|
||||
"helpTitle": "Reporting Issues",
|
||||
"helpText": "When reporting bugs, click \"Copy Debug Info\" to get system information and recent errors that help us diagnose the issue."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Server Overview",
|
||||
"titleWithProject": "MCP Server Overview for {{projectName}}",
|
||||
"description": "Configure which MCP servers are available for agents in this project",
|
||||
"descriptionNoProject": "Select a project to configure MCP servers",
|
||||
"serversEnabled": "{{count}} servers enabled",
|
||||
"configuration": "MCP Server Configuration",
|
||||
"configurationHint": "Disabled servers reduce context usage and startup time",
|
||||
"noProjectSelected": "No Project Selected",
|
||||
"noProjectSelectedDescription": "Select a project from the dropdown to view and configure MCP servers.",
|
||||
"projectNotInitialized": "Project Not Initialized",
|
||||
"projectNotInitializedDescription": "Initialize Auto Claude for this project to configure MCP servers.",
|
||||
"browserAutomation": "Browser Automation (QA agents only)",
|
||||
"alwaysEnabled": "always enabled",
|
||||
"addServer": "Add Server",
|
||||
"addMcpTo": "Add MCP Server to {{agent}}",
|
||||
"addMcpDescription": "Select an MCP server to add to this agent",
|
||||
"allMcpsAdded": "All available MCP servers are already added",
|
||||
"added": "added",
|
||||
"removed": "removed",
|
||||
"remove": "Remove",
|
||||
"restore": "Restore",
|
||||
"noMcpServers": "No MCP servers",
|
||||
"cannotRemove": "Cannot remove (required)",
|
||||
"servers": {
|
||||
"context7": {
|
||||
"name": "Context7",
|
||||
"description": "Documentation lookup for libraries"
|
||||
},
|
||||
"graphiti": {
|
||||
"name": "Graphiti Memory",
|
||||
"description": "Knowledge graph for cross-session context",
|
||||
"notConfigured": "Requires memory configuration (see Memory settings)"
|
||||
},
|
||||
"linear": {
|
||||
"name": "Linear",
|
||||
"description": "Project management integration",
|
||||
"notConfigured": "Requires Linear integration (see Linear settings)"
|
||||
},
|
||||
"electron": {
|
||||
"name": "Electron",
|
||||
"description": "Desktop app automation via Chrome DevTools"
|
||||
},
|
||||
"puppeteer": {
|
||||
"name": "Puppeteer",
|
||||
"description": "Web browser automation for testing"
|
||||
},
|
||||
"autoClaude": {
|
||||
"name": "Auto-Claude Tools",
|
||||
"description": "Build progress tracking"
|
||||
}
|
||||
},
|
||||
"customServers": "Custom Servers",
|
||||
"addCustomServer": "Add Custom Server",
|
||||
"editCustomServer": "Edit Custom Server",
|
||||
"customServerDescription": "Add a command-based or HTTP-based MCP server",
|
||||
"serverType": "Server Type",
|
||||
"typeCommand": "Command (npx/npm)",
|
||||
"typeHttp": "HTTP",
|
||||
"serverName": "Name",
|
||||
"serverNamePlaceholder": "My MCP Server",
|
||||
"serverDescription": "Description",
|
||||
"serverDescriptionPlaceholder": "What this server does",
|
||||
"command": "Command",
|
||||
"args": "Arguments",
|
||||
"argsHint": "Space-separated arguments",
|
||||
"url": "URL",
|
||||
"headers": "Headers",
|
||||
"headerName": "Header Name",
|
||||
"headerValue": "Header Value",
|
||||
"noCustomServers": "No custom servers configured. Add one to use with your agents.",
|
||||
"errorNameRequired": "Server name is required",
|
||||
"errorIdExists": "A server with this ID already exists",
|
||||
"errorCommandRequired": "Command is required for command-based servers",
|
||||
"errorUrlRequired": "URL is required for HTTP-based servers",
|
||||
"testConnection": "Test",
|
||||
"testing": "Testing...",
|
||||
"authToken": "Authentication Token",
|
||||
"authTokenPlaceholder": "Paste your API token or PAT here",
|
||||
"authTokenHint": "Used as Bearer token in the Authorization header",
|
||||
"advancedHeaders": "Additional Headers",
|
||||
"status": {
|
||||
"healthy": "Server is responding",
|
||||
"unhealthy": "Server is not responding",
|
||||
"needsAuth": "Authentication required",
|
||||
"checking": "Checking...",
|
||||
"unknown": "Status unknown"
|
||||
},
|
||||
"hints": {
|
||||
"github": "This looks like a GitHub MCP server. You'll need a Personal Access Token with appropriate scopes.",
|
||||
"createGithubPat": "Create GitHub PAT",
|
||||
"google": "This looks like a Google API. You'll need an OAuth token or API key.",
|
||||
"createGoogleToken": "Create Google Credentials",
|
||||
"anthropic": "This looks like an Anthropic API. You'll need an API key.",
|
||||
"createAnthropicKey": "Create Anthropic API Key",
|
||||
"openai": "This looks like an OpenAI API. You'll need an API key.",
|
||||
"createOpenaiKey": "Create OpenAI API Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,14 +169,14 @@
|
||||
"findingsPosted": "{{count}} publiés",
|
||||
"followupInProgress": "Analyse de suivi en cours...",
|
||||
"severity": {
|
||||
"critical": "Critique",
|
||||
"high": "Élevée",
|
||||
"medium": "Moyenne",
|
||||
"low": "Faible",
|
||||
"criticalDesc": "Doit être corrigé avant fusion",
|
||||
"highDesc": "Devrait être corrigé avant fusion",
|
||||
"mediumDesc": "À considérer",
|
||||
"lowDesc": "Optionnel"
|
||||
"critical": "Bloquant",
|
||||
"high": "Requis",
|
||||
"medium": "Recommandé",
|
||||
"low": "Suggestion",
|
||||
"criticalDesc": "À corriger",
|
||||
"highDesc": "À traiter",
|
||||
"mediumDesc": "Améliore la qualité",
|
||||
"lowDesc": "À considérer"
|
||||
},
|
||||
"category": {
|
||||
"security": "Sécurité",
|
||||
@@ -193,10 +193,13 @@
|
||||
"closed": "Fermé",
|
||||
"merged": "Fusionné"
|
||||
},
|
||||
"selectCriticalHigh": "Sélectionner Critique/Élevée ({{count}})",
|
||||
"selectCriticalHigh": "Sélectionner Bloquant/Requis ({{count}})",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clear": "Effacer",
|
||||
"noIssuesFound": "Aucun problème trouvé ! Le code est bon.",
|
||||
"allFindingsPosted": "Tous les problèmes ont été publiés sur GitHub",
|
||||
"findingsPostedCount": "{{count}} problème publié sur GitHub",
|
||||
"findingsPostedCount_plural": "{{count}} problèmes publiés sur GitHub",
|
||||
"selectedOfTotal": "{{selected}}/{{total}} sélectionnés",
|
||||
"suggestedFix": "Correction suggérée :",
|
||||
"runAIReviewDesc": "Lancez une révision IA pour analyser cette PR",
|
||||
@@ -220,7 +223,10 @@
|
||||
"findingsNeedPosting": "{{count}} résultat doit être publié sur GitHub.",
|
||||
"findingsNeedPosting_plural": "{{count}} résultats doivent être publiés sur GitHub.",
|
||||
"findingsFoundSelectPost": "{{count}} résultat trouvé. Sélectionnez et publiez sur GitHub.",
|
||||
"findingsFoundSelectPost_plural": "{{count}} résultats trouvés. Sélectionnez et publiez sur GitHub."
|
||||
"findingsFoundSelectPost_plural": "{{count}} résultats trouvés. Sélectionnez et publiez sur GitHub.",
|
||||
"reviewLogs": "Journaux de révision",
|
||||
"followup": "Suivi",
|
||||
"initial": "Initial"
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Afficher/masquer les détails",
|
||||
|
||||
@@ -100,7 +100,17 @@
|
||||
"noBranchesFound": "Aucune branche trouvée",
|
||||
"branchFromNote": "Toutes les nouvelles tâches partiront de",
|
||||
"autoSyncOnLoad": "Sync auto au chargement",
|
||||
"autoSyncDescription": "Récupérer automatiquement les issues au chargement du projet"
|
||||
"autoSyncDescription": "Récupérer automatiquement les issues au chargement du projet",
|
||||
"cli": {
|
||||
"required": "GitLab CLI requis",
|
||||
"notInstalled": "La CLI GitLab (glab) est requise pour l'authentification OAuth. Installez-la pour utiliser l'option 'Utiliser OAuth'.",
|
||||
"installButton": "Installer glab",
|
||||
"installing": "Installation...",
|
||||
"installSuccess": "Installation démarrée dans votre terminal. Terminez-la et cliquez sur Actualiser.",
|
||||
"refresh": "Actualiser",
|
||||
"learnMore": "En savoir plus",
|
||||
"installed": "GitLab CLI installé :"
|
||||
}
|
||||
},
|
||||
"mergeRequests": {
|
||||
"title": "Merge Requests GitLab",
|
||||
@@ -159,6 +169,16 @@
|
||||
"requestChanges": "Modifications demandées",
|
||||
"comment": "Commentaire"
|
||||
},
|
||||
"severity": {
|
||||
"critical": "Bloquant",
|
||||
"criticalDesc": "À corriger",
|
||||
"high": "Requis",
|
||||
"highDesc": "À traiter",
|
||||
"medium": "Recommandé",
|
||||
"mediumDesc": "Améliore la qualité",
|
||||
"low": "Suggestion",
|
||||
"lowDesc": "À considérer"
|
||||
},
|
||||
"resolution": {
|
||||
"resolved": "résolu(s)",
|
||||
"stillOpen": "encore ouvert(s)",
|
||||
@@ -168,21 +188,21 @@
|
||||
},
|
||||
"findings": {
|
||||
"summary": "sélectionné(s)",
|
||||
"selectCriticalHigh": "Sélectionner Critique/Élevé",
|
||||
"selectCriticalHigh": "Sélectionner Bloquant/Requis",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clear": "Effacer",
|
||||
"noIssues": "Aucun problème trouvé ! Le code est bon.",
|
||||
"suggestedFix": "Correction suggérée :",
|
||||
"posted": "Publié",
|
||||
"severity": {
|
||||
"critical": "Critique",
|
||||
"criticalDesc": "À corriger avant fusion",
|
||||
"high": "Élevé",
|
||||
"highDesc": "Devrait être corrigé avant fusion",
|
||||
"medium": "Moyen",
|
||||
"mediumDesc": "À considérer",
|
||||
"low": "Faible",
|
||||
"lowDesc": "Optionnel"
|
||||
"critical": "Bloquant",
|
||||
"criticalDesc": "À corriger",
|
||||
"high": "Requis",
|
||||
"highDesc": "À traiter",
|
||||
"medium": "Recommandé",
|
||||
"mediumDesc": "Améliore la qualité",
|
||||
"low": "Suggestion",
|
||||
"lowDesc": "À considérer"
|
||||
},
|
||||
"category": {
|
||||
"security": "Sécurité",
|
||||
|
||||
@@ -29,5 +29,22 @@
|
||||
},
|
||||
"messages": {
|
||||
"initializeToCreateTasks": "Initialisez Auto Claude pour créer des tâches"
|
||||
},
|
||||
"claudeCode": {
|
||||
"checking": "Vérification de Claude Code...",
|
||||
"upToDate": "Claude Code est à jour",
|
||||
"updateAvailable": "Mise à jour Claude Code disponible",
|
||||
"notInstalled": "Claude Code non installé",
|
||||
"error": "Erreur de vérification de Claude Code",
|
||||
"installed": "Installé",
|
||||
"outdated": "Mise à jour disponible",
|
||||
"missing": "Non installé",
|
||||
"current": "Actuelle",
|
||||
"latest": "Dernière",
|
||||
"lastChecked": "Dernière vérification",
|
||||
"learnMore": "En savoir plus sur Claude Code",
|
||||
"updateWarningTitle": "Mettre à jour Claude Code ?",
|
||||
"updateWarningDescription": "La mise à jour fermera toutes les sessions Claude Code en cours. Tout travail non sauvegardé dans ces sessions pourrait être perdu. Assurez-vous de sauvegarder votre travail avant de continuer.",
|
||||
"updateAnyway": "Mettre à jour quand même"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,37 @@
|
||||
"steps": {
|
||||
"welcome": "Bienvenue",
|
||||
"auth": "Auth",
|
||||
"claudeCode": "CLI",
|
||||
"devtools": "Outils dev",
|
||||
"memory": "Mémoire",
|
||||
"done": "Terminé"
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code CLI",
|
||||
"description": "Installez ou mettez à jour le CLI Claude Code pour activer les fonctionnalités IA",
|
||||
"detecting": "Vérification de l'installation de Claude Code...",
|
||||
"info": {
|
||||
"title": "Qu'est-ce que Claude Code ?",
|
||||
"description": "Claude Code est le CLI officiel d'Anthropic qui alimente les fonctionnalités IA d'Auto Claude. Il fournit une authentification sécurisée et un accès direct aux modèles Claude."
|
||||
},
|
||||
"status": {
|
||||
"installed": "Installé",
|
||||
"outdated": "Mise à jour disponible",
|
||||
"notFound": "Non installé"
|
||||
},
|
||||
"version": {
|
||||
"current": "Version actuelle",
|
||||
"latest": "Dernière version"
|
||||
},
|
||||
"install": {
|
||||
"button": "Installer Claude Code",
|
||||
"updating": "Mettre à jour Claude Code",
|
||||
"inProgress": "Installation...",
|
||||
"success": "Commande d'installation envoyée au terminal. Veuillez terminer l'installation là-bas.",
|
||||
"instructions": "L'installateur s'ouvrira dans votre terminal. Suivez les instructions pour terminer l'installation."
|
||||
},
|
||||
"learnMore": "En savoir plus sur Claude Code"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Outils de développement",
|
||||
"description": "Choisissez votre IDE et terminal préférés pour travailler avec les worktrees Auto Claude",
|
||||
|
||||
@@ -318,5 +318,104 @@
|
||||
"noRecentErrors": "Aucune erreur récente",
|
||||
"helpTitle": "Signaler des problèmes",
|
||||
"helpText": "Lors du signalement de bugs, cliquez sur \"Copier les infos de débogage\" pour obtenir les informations système et les erreurs récentes qui nous aident à diagnostiquer le problème."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Aperçu des serveurs MCP",
|
||||
"titleWithProject": "Aperçu des serveurs MCP pour {{projectName}}",
|
||||
"description": "Configurez quels serveurs MCP sont disponibles pour les agents dans ce projet",
|
||||
"descriptionNoProject": "Sélectionnez un projet pour configurer les serveurs MCP",
|
||||
"serversEnabled": "{{count}} serveurs activés",
|
||||
"configuration": "Configuration des serveurs MCP",
|
||||
"configurationHint": "Les serveurs désactivés réduisent l'utilisation du contexte et le temps de démarrage",
|
||||
"noProjectSelected": "Aucun projet sélectionné",
|
||||
"noProjectSelectedDescription": "Sélectionnez un projet dans le menu déroulant pour voir et configurer les serveurs MCP.",
|
||||
"projectNotInitialized": "Projet non initialisé",
|
||||
"projectNotInitializedDescription": "Initialisez Auto Claude pour ce projet pour configurer les serveurs MCP.",
|
||||
"browserAutomation": "Automatisation du navigateur (agents QA uniquement)",
|
||||
"alwaysEnabled": "toujours activé",
|
||||
"addServer": "Ajouter un serveur",
|
||||
"addMcpTo": "Ajouter un serveur MCP à {{agent}}",
|
||||
"addMcpDescription": "Sélectionnez un serveur MCP à ajouter à cet agent",
|
||||
"allMcpsAdded": "Tous les serveurs MCP disponibles sont déjà ajoutés",
|
||||
"added": "ajouté",
|
||||
"removed": "supprimé",
|
||||
"remove": "Supprimer",
|
||||
"restore": "Restaurer",
|
||||
"noMcpServers": "Aucun serveur MCP",
|
||||
"cannotRemove": "Impossible à supprimer (requis)",
|
||||
"servers": {
|
||||
"context7": {
|
||||
"name": "Context7",
|
||||
"description": "Recherche de documentation pour les bibliothèques"
|
||||
},
|
||||
"graphiti": {
|
||||
"name": "Graphiti Memory",
|
||||
"description": "Graphe de connaissances pour le contexte inter-sessions",
|
||||
"notConfigured": "Nécessite la configuration mémoire (voir paramètres Mémoire)"
|
||||
},
|
||||
"linear": {
|
||||
"name": "Linear",
|
||||
"description": "Intégration gestion de projet",
|
||||
"notConfigured": "Nécessite l'intégration Linear (voir paramètres Linear)"
|
||||
},
|
||||
"electron": {
|
||||
"name": "Electron",
|
||||
"description": "Automatisation d'applications desktop via Chrome DevTools"
|
||||
},
|
||||
"puppeteer": {
|
||||
"name": "Puppeteer",
|
||||
"description": "Automatisation du navigateur web pour les tests"
|
||||
},
|
||||
"autoClaude": {
|
||||
"name": "Outils Auto-Claude",
|
||||
"description": "Suivi de la progression du build"
|
||||
}
|
||||
},
|
||||
"customServers": "Serveurs personnalisés",
|
||||
"addCustomServer": "Ajouter un serveur personnalisé",
|
||||
"editCustomServer": "Modifier le serveur personnalisé",
|
||||
"customServerDescription": "Ajouter un serveur MCP basé sur une commande ou HTTP",
|
||||
"serverType": "Type de serveur",
|
||||
"typeCommand": "Commande (npx/npm)",
|
||||
"typeHttp": "HTTP",
|
||||
"serverName": "Nom",
|
||||
"serverNamePlaceholder": "Mon serveur MCP",
|
||||
"serverDescription": "Description",
|
||||
"serverDescriptionPlaceholder": "Ce que fait ce serveur",
|
||||
"command": "Commande",
|
||||
"args": "Arguments",
|
||||
"argsHint": "Arguments séparés par des espaces",
|
||||
"url": "URL",
|
||||
"headers": "En-têtes",
|
||||
"headerName": "Nom de l'en-tête",
|
||||
"headerValue": "Valeur de l'en-tête",
|
||||
"noCustomServers": "Aucun serveur personnalisé configuré. Ajoutez-en un pour l'utiliser avec vos agents.",
|
||||
"errorNameRequired": "Le nom du serveur est requis",
|
||||
"errorIdExists": "Un serveur avec cet ID existe déjà",
|
||||
"errorCommandRequired": "La commande est requise pour les serveurs basés sur commande",
|
||||
"errorUrlRequired": "L'URL est requise pour les serveurs basés sur HTTP",
|
||||
"testConnection": "Test",
|
||||
"testing": "Test en cours...",
|
||||
"authToken": "Jeton d'authentification",
|
||||
"authTokenPlaceholder": "Collez votre jeton API ou PAT ici",
|
||||
"authTokenHint": "Utilisé comme jeton Bearer dans l'en-tête Authorization",
|
||||
"advancedHeaders": "En-têtes supplémentaires",
|
||||
"status": {
|
||||
"healthy": "Le serveur répond",
|
||||
"unhealthy": "Le serveur ne répond pas",
|
||||
"needsAuth": "Authentification requise",
|
||||
"checking": "Vérification...",
|
||||
"unknown": "Statut inconnu"
|
||||
},
|
||||
"hints": {
|
||||
"github": "Ceci ressemble à un serveur MCP GitHub. Vous aurez besoin d'un Personal Access Token avec les scopes appropriés.",
|
||||
"createGithubPat": "Créer un PAT GitHub",
|
||||
"google": "Ceci ressemble à une API Google. Vous aurez besoin d'un jeton OAuth ou d'une clé API.",
|
||||
"createGoogleToken": "Créer des identifiants Google",
|
||||
"anthropic": "Ceci ressemble à une API Anthropic. Vous aurez besoin d'une clé API.",
|
||||
"createAnthropicKey": "Créer une clé API Anthropic",
|
||||
"openai": "Ceci ressemble à une API OpenAI. Vous aurez besoin d'une clé API.",
|
||||
"createOpenaiKey": "Créer une clé API OpenAI"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ export interface ToolDetectionResult {
|
||||
| 'fallback';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code CLI version information
|
||||
* Used for version checking and update prompts
|
||||
*/
|
||||
export interface ClaudeCodeVersionInfo {
|
||||
/** Currently installed version, null if not installed */
|
||||
installed: string | null;
|
||||
/** Latest version available from npm registry */
|
||||
latest: string;
|
||||
/** True if installed version is older than latest */
|
||||
isOutdated: boolean;
|
||||
/** Path to Claude CLI binary if found */
|
||||
path?: string;
|
||||
/** Full detection result with source information */
|
||||
detectionResult: ToolDetectionResult;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import type {
|
||||
InfrastructureStatus,
|
||||
GraphitiValidationResult,
|
||||
GraphitiConnectionTestResult,
|
||||
GitStatus
|
||||
GitStatus,
|
||||
CustomMcpServer,
|
||||
McpHealthCheckResult,
|
||||
McpTestConnectionResult
|
||||
} from './project';
|
||||
import type {
|
||||
Task,
|
||||
@@ -452,6 +455,7 @@ export interface ElectronAPI {
|
||||
|
||||
// GitLab OAuth operations (glab CLI)
|
||||
checkGitLabCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
|
||||
installGitLabCli: () => Promise<IPCResult<{ command: string }>>;
|
||||
checkGitLabAuth: (hostname?: string) => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
|
||||
startGitLabAuth: (hostname?: string) => Promise<IPCResult<{
|
||||
success: boolean;
|
||||
@@ -718,6 +722,10 @@ export interface ElectronAPI {
|
||||
// GitHub API (nested for organized access)
|
||||
github: import('../../preload/api/modules/github-api').GitHubAPI;
|
||||
|
||||
// Claude Code CLI operations
|
||||
checkClaudeCodeVersion: () => Promise<IPCResult<import('./cli').ClaudeCodeVersionInfo>>;
|
||||
installClaudeCode: () => Promise<IPCResult<{ command: string }>>;
|
||||
|
||||
// Debug operations
|
||||
getDebugInfo: () => Promise<{
|
||||
systemInfo: Record<string, string>;
|
||||
@@ -734,6 +742,10 @@ export interface ElectronAPI {
|
||||
size: number;
|
||||
modified: string;
|
||||
}>>;
|
||||
|
||||
// MCP Server health check operations
|
||||
checkMcpHealth: (server: CustomMcpServer) => Promise<IPCResult<McpHealthCheckResult>>;
|
||||
testMcpConnection: (server: CustomMcpServer) => Promise<IPCResult<McpTestConnectionResult>>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -318,6 +318,109 @@ export interface ProjectEnvConfig {
|
||||
|
||||
// UI Settings
|
||||
enableFancyUi: boolean;
|
||||
|
||||
// MCP Server Configuration (per-project overrides)
|
||||
mcpServers?: {
|
||||
/** Context7 documentation lookup - default: true */
|
||||
context7Enabled?: boolean;
|
||||
/** Graphiti knowledge graph - default: true (if graphitiProviderConfig set) */
|
||||
graphitiEnabled?: boolean;
|
||||
/** Linear MCP integration - default: follows linearEnabled */
|
||||
linearMcpEnabled?: boolean;
|
||||
/** Electron desktop automation (QA only) - default: false */
|
||||
electronEnabled?: boolean;
|
||||
/** Puppeteer browser automation (QA only) - default: false */
|
||||
puppeteerEnabled?: boolean;
|
||||
};
|
||||
|
||||
// Per-agent MCP overrides (add/remove MCPs from specific agents)
|
||||
agentMcpOverrides?: AgentMcpOverrides;
|
||||
|
||||
// Custom MCP servers defined by the user
|
||||
customMcpServers?: CustomMcpServer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent MCP override configuration.
|
||||
* Stored in .auto-claude/.env as AGENT_MCP_<agent>_ADD and AGENT_MCP_<agent>_REMOVE
|
||||
*/
|
||||
export interface AgentMcpOverride {
|
||||
/** MCP servers to add beyond the agent's defaults */
|
||||
add?: string[];
|
||||
/** MCP servers to remove from the agent's defaults */
|
||||
remove?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of agent type to their MCP overrides.
|
||||
* Agent types match backend AGENT_CONFIGS keys (e.g., 'planner', 'coder', 'qa_reviewer')
|
||||
*/
|
||||
export interface AgentMcpOverrides {
|
||||
[agentType: string]: AgentMcpOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom MCP server configuration.
|
||||
* Users can add command-based (npx/npm) or HTTP-based servers.
|
||||
*/
|
||||
export interface CustomMcpServer {
|
||||
/** Unique identifier (used for agent overrides: AGENT_MCP_<agent>_ADD=myserver) */
|
||||
id: string;
|
||||
/** Display name shown in UI */
|
||||
name: string;
|
||||
/** Server type */
|
||||
type: 'command' | 'http';
|
||||
/** Command to execute (for type: 'command'). e.g., 'npx', 'npm', 'node' */
|
||||
command?: string;
|
||||
/** Arguments for the command (for type: 'command'). e.g., ['-y', 'my-mcp-server'] */
|
||||
args?: string[];
|
||||
/** HTTP URL (for type: 'http'). e.g., 'https://mcp.example.com/mcp' */
|
||||
url?: string;
|
||||
/** HTTP headers (for type: 'http'). e.g., { "Authorization": "Bearer ..." } */
|
||||
headers?: Record<string, string>;
|
||||
/** Optional description shown in UI */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP server health check status.
|
||||
*/
|
||||
export type McpHealthStatus = 'healthy' | 'unhealthy' | 'needs_auth' | 'unknown' | 'checking';
|
||||
|
||||
/**
|
||||
* Result of a quick health check for a custom MCP server.
|
||||
*/
|
||||
export interface McpHealthCheckResult {
|
||||
/** Server ID */
|
||||
serverId: string;
|
||||
/** Health status */
|
||||
status: McpHealthStatus;
|
||||
/** HTTP status code (for HTTP servers) */
|
||||
statusCode?: number;
|
||||
/** Human-readable message */
|
||||
message?: string;
|
||||
/** Response time in milliseconds */
|
||||
responseTime?: number;
|
||||
/** Timestamp of the check */
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a full MCP connection test.
|
||||
*/
|
||||
export interface McpTestConnectionResult {
|
||||
/** Server ID */
|
||||
serverId: string;
|
||||
/** Whether the connection was successful */
|
||||
success: boolean;
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
/** Detailed error if any */
|
||||
error?: string;
|
||||
/** List of tools discovered (for successful connections) */
|
||||
tools?: string[];
|
||||
/** Response time in milliseconds */
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
// Auto Claude Initialization Types
|
||||
|
||||
@@ -187,6 +187,7 @@ export interface FeatureModelConfig {
|
||||
roadmap: ModelTypeShort; // Roadmap generation
|
||||
githubIssues: ModelTypeShort; // GitHub Issues automation
|
||||
githubPrs: ModelTypeShort; // GitHub PR review automation
|
||||
utility: ModelTypeShort; // Utility agents (commit message, merge resolver)
|
||||
}
|
||||
|
||||
// Feature-specific thinking level configuration
|
||||
@@ -196,6 +197,7 @@ export interface FeatureThinkingConfig {
|
||||
roadmap: ThinkingLevel;
|
||||
githubIssues: ThinkingLevel;
|
||||
githubPrs: ThinkingLevel;
|
||||
utility: ThinkingLevel;
|
||||
}
|
||||
|
||||
// Agent profile for preset model/thinking configurations
|
||||
|
||||
@@ -161,16 +161,31 @@ class TestGetRequiredMcpServers:
|
||||
os.environ.pop("ELECTRON_MCP_ENABLED", None)
|
||||
|
||||
def test_browser_resolved_to_puppeteer_for_web_frontend(self):
|
||||
"""Browser should resolve to 'puppeteer' for web frontend projects."""
|
||||
"""Browser should resolve to 'puppeteer' for web frontend projects when enabled."""
|
||||
from agents.tools_pkg.models import get_required_mcp_servers
|
||||
|
||||
# Puppeteer requires explicit opt-in via project config
|
||||
servers = get_required_mcp_servers(
|
||||
"qa_reviewer", project_capabilities={"is_web_frontend": True, "is_electron": False}
|
||||
"qa_reviewer",
|
||||
project_capabilities={"is_web_frontend": True, "is_electron": False},
|
||||
mcp_config={"PUPPETEER_MCP_ENABLED": "true"},
|
||||
)
|
||||
assert "puppeteer" in servers
|
||||
assert "browser" not in servers
|
||||
assert "electron" not in servers
|
||||
|
||||
def test_puppeteer_not_included_when_disabled(self):
|
||||
"""Puppeteer should NOT be included when not explicitly enabled (default)."""
|
||||
from agents.tools_pkg.models import get_required_mcp_servers
|
||||
|
||||
# Default behavior: puppeteer is NOT auto-enabled for web frontends
|
||||
servers = get_required_mcp_servers(
|
||||
"qa_reviewer",
|
||||
project_capabilities={"is_web_frontend": True, "is_electron": False},
|
||||
)
|
||||
assert "puppeteer" not in servers
|
||||
assert "browser" not in servers
|
||||
|
||||
|
||||
class TestGetDefaultThinkingLevel:
|
||||
"""Tests for get_default_thinking_level() function."""
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Tests for Bot Detection Module
|
||||
================================
|
||||
|
||||
Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the backend runners/github directory to path
|
||||
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
_github_dir = _backend_dir / "runners" / "github"
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
|
||||
from bot_detection import BotDetectionState, BotDetector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_state_dir(tmp_path):
|
||||
"""Create temporary state directory."""
|
||||
state_dir = tmp_path / "github"
|
||||
state_dir.mkdir()
|
||||
return state_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot_detector(temp_state_dir):
|
||||
"""Create bot detector with mocked bot username."""
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
return detector
|
||||
|
||||
|
||||
class TestBotDetectionState:
|
||||
"""Test BotDetectionState data class."""
|
||||
|
||||
def test_save_and_load(self, temp_state_dir):
|
||||
"""Test saving and loading state."""
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={
|
||||
"123": ["abc123", "def456"],
|
||||
"456": ["ghi789"],
|
||||
},
|
||||
last_review_times={
|
||||
"123": "2025-01-01T10:00:00",
|
||||
"456": "2025-01-01T11:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
# Save
|
||||
state.save(temp_state_dir)
|
||||
|
||||
# Load
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
|
||||
assert loaded.reviewed_commits == state.reviewed_commits
|
||||
assert loaded.last_review_times == state.last_review_times
|
||||
|
||||
def test_load_nonexistent(self, temp_state_dir):
|
||||
"""Test loading when file doesn't exist."""
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
|
||||
assert loaded.reviewed_commits == {}
|
||||
assert loaded.last_review_times == {}
|
||||
|
||||
|
||||
class TestBotDetectorInit:
|
||||
"""Test BotDetector initialization."""
|
||||
|
||||
def test_init_with_token(self, temp_state_dir):
|
||||
"""Test initialization with bot token."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"login": "my-bot"}),
|
||||
)
|
||||
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="ghp_test123",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
assert detector.bot_username == "my-bot"
|
||||
assert detector.review_own_prs is False
|
||||
|
||||
def test_init_without_token(self, temp_state_dir):
|
||||
"""Test initialization without bot token."""
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token=None,
|
||||
review_own_prs=True,
|
||||
)
|
||||
|
||||
assert detector.bot_username is None
|
||||
assert detector.review_own_prs is True
|
||||
|
||||
|
||||
class TestBotDetection:
|
||||
"""Test bot detection methods."""
|
||||
|
||||
def test_is_bot_pr(self, mock_bot_detector):
|
||||
"""Test detecting bot-authored PRs."""
|
||||
bot_pr = {"author": {"login": "test-bot"}}
|
||||
human_pr = {"author": {"login": "alice"}}
|
||||
|
||||
assert mock_bot_detector.is_bot_pr(bot_pr) is True
|
||||
assert mock_bot_detector.is_bot_pr(human_pr) is False
|
||||
|
||||
def test_is_bot_commit(self, mock_bot_detector):
|
||||
"""Test detecting bot-authored commits."""
|
||||
bot_commit = {"author": {"login": "test-bot"}}
|
||||
human_commit = {"author": {"login": "alice"}}
|
||||
bot_committer = {
|
||||
"committer": {"login": "test-bot"},
|
||||
"author": {"login": "alice"},
|
||||
}
|
||||
|
||||
assert mock_bot_detector.is_bot_commit(bot_commit) is True
|
||||
assert mock_bot_detector.is_bot_commit(human_commit) is False
|
||||
assert mock_bot_detector.is_bot_commit(bot_committer) is True
|
||||
|
||||
def test_get_last_commit_sha(self, mock_bot_detector):
|
||||
"""Test extracting last commit SHA."""
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit
|
||||
commits = [
|
||||
{"oid": "abc123"}, # Oldest commit
|
||||
{"oid": "def456"}, # Latest commit
|
||||
]
|
||||
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits)
|
||||
assert sha == "def456" # Should return the LAST (latest) commit
|
||||
|
||||
# Test with sha field instead of oid
|
||||
commits_with_sha = [{"sha": "xyz789"}]
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits_with_sha)
|
||||
assert sha == "xyz789"
|
||||
|
||||
# Empty commits
|
||||
assert mock_bot_detector.get_last_commit_sha([]) is None
|
||||
|
||||
|
||||
class TestCoolingOff:
|
||||
"""Test cooling off period.
|
||||
|
||||
Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
|
||||
"""
|
||||
|
||||
def test_within_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR within cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
assert is_cooling is True
|
||||
assert "Cooling off" in reason
|
||||
|
||||
def test_outside_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR outside cooling off period."""
|
||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
assert is_cooling is False
|
||||
assert reason == ""
|
||||
|
||||
def test_no_previous_review(self, mock_bot_detector):
|
||||
"""Test PR with no previous review."""
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(999)
|
||||
|
||||
assert is_cooling is False
|
||||
assert reason == ""
|
||||
|
||||
|
||||
class TestReviewedCommits:
|
||||
"""Test reviewed commit tracking."""
|
||||
|
||||
def test_has_reviewed_commit(self, mock_bot_detector):
|
||||
"""Test checking if commit was reviewed."""
|
||||
mock_bot_detector.state.reviewed_commits["123"] = ["abc123", "def456"]
|
||||
|
||||
assert mock_bot_detector.has_reviewed_commit(123, "abc123") is True
|
||||
assert mock_bot_detector.has_reviewed_commit(123, "xyz789") is False
|
||||
assert mock_bot_detector.has_reviewed_commit(999, "abc123") is False
|
||||
|
||||
def test_mark_reviewed(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking PR as reviewed."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Check state
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
|
||||
assert "123" in mock_bot_detector.state.last_review_times
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" in loaded.reviewed_commits
|
||||
assert "abc123" in loaded.reviewed_commits["123"]
|
||||
|
||||
def test_mark_reviewed_multiple(self, mock_bot_detector):
|
||||
"""Test marking same PR reviewed multiple times."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(123, "def456")
|
||||
|
||||
commits = mock_bot_detector.state.reviewed_commits["123"]
|
||||
assert len(commits) == 2
|
||||
assert "abc123" in commits
|
||||
assert "def456" in commits
|
||||
|
||||
|
||||
class TestShouldSkipReview:
|
||||
"""Test main should_skip_pr_review logic."""
|
||||
|
||||
def test_skip_bot_pr(self, mock_bot_detector):
|
||||
"""Test skipping bot-authored PR."""
|
||||
pr_data = {"author": {"login": "test-bot"}}
|
||||
commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "bot user" in reason
|
||||
|
||||
def test_skip_bot_commit(self, mock_bot_detector):
|
||||
"""Test skipping PR with bot commit as the latest commit."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit - which is the bot commit
|
||||
commits = [
|
||||
{"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
|
||||
{"author": {"login": "test-bot"}, "oid": "def456"}, # Latest commit (by bot)
|
||||
]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "bot" in reason.lower()
|
||||
|
||||
def test_skip_cooling_off(self, mock_bot_detector):
|
||||
"""Test skipping during cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "Cooling off" in reason
|
||||
|
||||
def test_skip_already_reviewed(self, mock_bot_detector):
|
||||
"""Test skipping already-reviewed commit."""
|
||||
mock_bot_detector.state.reviewed_commits["123"] = ["abc123"]
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "Already reviewed" in reason
|
||||
|
||||
def test_allow_review(self, mock_bot_detector):
|
||||
"""Test allowing review when all checks pass."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is False
|
||||
assert reason == ""
|
||||
|
||||
def test_allow_review_own_prs(self, temp_state_dir):
|
||||
"""Test allowing review when review_own_prs is True."""
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=True, # Allow bot to review own PRs
|
||||
)
|
||||
|
||||
pr_data = {"author": {"login": "test-bot"}}
|
||||
commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
# Should not skip even though it's bot's own PR
|
||||
assert should_skip is False
|
||||
|
||||
|
||||
class TestStateManagement:
|
||||
"""Test state management methods."""
|
||||
|
||||
def test_clear_pr_state(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test clearing PR state."""
|
||||
# Set up state
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(456, "def456")
|
||||
|
||||
# Clear one PR
|
||||
mock_bot_detector.clear_pr_state(123)
|
||||
|
||||
# Check in-memory state
|
||||
assert "123" not in mock_bot_detector.state.reviewed_commits
|
||||
assert "123" not in mock_bot_detector.state.last_review_times
|
||||
assert "456" in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" not in loaded.reviewed_commits
|
||||
assert "456" in loaded.reviewed_commits
|
||||
|
||||
def test_get_stats(self, mock_bot_detector):
|
||||
"""Test getting detector statistics."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(123, "def456")
|
||||
mock_bot_detector.mark_reviewed(456, "ghi789")
|
||||
|
||||
stats = mock_bot_detector.get_stats()
|
||||
|
||||
assert stats["bot_username"] == "test-bot"
|
||||
assert stats["review_own_prs"] is False
|
||||
assert stats["total_prs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 3
|
||||
assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_no_commits(self, mock_bot_detector):
|
||||
"""Test handling PR with no commits."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = []
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
# Should not skip (no bot commit to detect)
|
||||
assert should_skip is False
|
||||
|
||||
def test_malformed_commit_data(self, mock_bot_detector):
|
||||
"""Test handling malformed commit data."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [
|
||||
{"author": {"login": "alice"}}, # Missing oid/sha
|
||||
{}, # Empty commit
|
||||
]
|
||||
|
||||
# Should not crash
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is False
|
||||
|
||||
def test_invalid_last_review_time(self, mock_bot_detector):
|
||||
"""Test handling invalid timestamp in state."""
|
||||
mock_bot_detector.state.last_review_times["123"] = "invalid-timestamp"
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
# Should not crash, should return False
|
||||
assert is_cooling is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
End-to-End Tests for GitHub PR Review System
|
||||
=============================================
|
||||
|
||||
Tests the full PR review flow with mocked external dependencies.
|
||||
These tests validate the integration between components.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the backend directory to path
|
||||
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
_github_dir = _backend_dir / "runners" / "github"
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
if str(_backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_backend_dir))
|
||||
|
||||
from models import (
|
||||
PRReviewResult,
|
||||
PRReviewFinding,
|
||||
ReviewSeverity,
|
||||
ReviewCategory,
|
||||
MergeVerdict,
|
||||
GitHubRunnerConfig,
|
||||
FollowupReviewContext,
|
||||
)
|
||||
from bot_detection import BotDetector
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def temp_github_dir(tmp_path):
|
||||
"""Create a temporary GitHub directory structure."""
|
||||
github_dir = tmp_path / ".auto-claude" / "github"
|
||||
pr_dir = github_dir / "pr"
|
||||
pr_dir.mkdir(parents=True)
|
||||
return github_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_config():
|
||||
"""Create a mock GitHub config."""
|
||||
return GitHubRunnerConfig(
|
||||
repo="test-owner/test-repo",
|
||||
token="ghp_test_token_12345",
|
||||
model="claude-sonnet-4-20250514",
|
||||
thinking_level="medium",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_review_with_findings():
|
||||
"""Create a sample review with findings."""
|
||||
return PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test-owner/test-repo",
|
||||
success=True,
|
||||
findings=[
|
||||
PRReviewFinding(
|
||||
id="finding-001",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection vulnerability",
|
||||
description="User input not sanitized",
|
||||
file="src/db.py",
|
||||
line=42,
|
||||
suggested_fix="Use parameterized queries",
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="finding-002",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Missing error handling",
|
||||
description="Exception not caught",
|
||||
file="src/api.py",
|
||||
line=100,
|
||||
suggested_fix="Add try-except block",
|
||||
fixable=True,
|
||||
),
|
||||
],
|
||||
summary="Found 2 issues: 1 high, 1 medium",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Security issues must be fixed",
|
||||
reviewed_commit_sha="abc123def456",
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
has_posted_findings=True,
|
||||
posted_finding_ids=["finding-001", "finding-002"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E2E Test: Review Result Persistence
|
||||
# ============================================================================
|
||||
|
||||
class TestReviewResultE2E:
|
||||
"""Test review result save/load flow end-to-end."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_load_review_with_findings(self, temp_github_dir, sample_review_with_findings):
|
||||
"""Test saving and loading a complete review result."""
|
||||
# Save the review
|
||||
await sample_review_with_findings.save(temp_github_dir)
|
||||
|
||||
# Verify file was created
|
||||
review_file = temp_github_dir / "pr" / "review_42.json"
|
||||
assert review_file.exists()
|
||||
|
||||
# Load and verify
|
||||
loaded = PRReviewResult.load(temp_github_dir, 42)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.pr_number == 42
|
||||
assert loaded.success is True
|
||||
assert len(loaded.findings) == 2
|
||||
assert loaded.findings[0].id == "finding-001"
|
||||
assert loaded.findings[0].severity == ReviewSeverity.HIGH
|
||||
assert loaded.findings[1].id == "finding-002"
|
||||
assert loaded.reviewed_commit_sha == "abc123def456"
|
||||
assert loaded.has_posted_findings is True
|
||||
assert len(loaded.posted_finding_ids) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_result_json_format(self, temp_github_dir, sample_review_with_findings):
|
||||
"""Test that saved JSON has correct format."""
|
||||
await sample_review_with_findings.save(temp_github_dir)
|
||||
|
||||
review_file = temp_github_dir / "pr" / "review_42.json"
|
||||
with open(review_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify key fields exist with snake_case
|
||||
assert "pr_number" in data
|
||||
assert "reviewed_commit_sha" in data
|
||||
assert "has_posted_findings" in data
|
||||
assert "posted_finding_ids" in data
|
||||
assert data["pr_number"] == 42
|
||||
assert isinstance(data["findings"], list)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E2E Test: Follow-up Review Flow
|
||||
# ============================================================================
|
||||
|
||||
class TestFollowupReviewE2E:
|
||||
"""Test follow-up review context and result flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_context_with_resolved_file(
|
||||
self, temp_github_dir, sample_review_with_findings
|
||||
):
|
||||
"""Test follow-up when the file with finding was modified."""
|
||||
# Save previous review
|
||||
await sample_review_with_findings.save(temp_github_dir)
|
||||
|
||||
# Create follow-up context where the file was changed
|
||||
context = FollowupReviewContext(
|
||||
pr_number=42,
|
||||
previous_review=sample_review_with_findings,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="new_commit_sha",
|
||||
files_changed_since_review=["src/db.py"], # File with finding-001
|
||||
diff_since_review="- unsanitized()\n+ parameterized()",
|
||||
)
|
||||
|
||||
# Verify context
|
||||
assert context.pr_number == 42
|
||||
assert "src/db.py" in context.files_changed_since_review
|
||||
assert context.error is None
|
||||
|
||||
# Simulate follow-up result (all issues resolved)
|
||||
followup_result = PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test-owner/test-repo",
|
||||
success=True,
|
||||
findings=[],
|
||||
summary="All previous issues resolved",
|
||||
overall_status="approve",
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
is_followup_review=True,
|
||||
resolved_findings=["finding-001"],
|
||||
unresolved_findings=["finding-002"], # api.py wasn't changed
|
||||
reviewed_commit_sha="new_commit_sha",
|
||||
previous_review_id="42",
|
||||
)
|
||||
|
||||
# Save and reload
|
||||
await followup_result.save(temp_github_dir)
|
||||
loaded = PRReviewResult.load(temp_github_dir, 42)
|
||||
|
||||
assert loaded.is_followup_review is True
|
||||
assert "finding-001" in loaded.resolved_findings
|
||||
assert "finding-002" in loaded.unresolved_findings
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_context_with_error(self, temp_github_dir, sample_review_with_findings):
|
||||
"""Test follow-up context when there's an error."""
|
||||
await sample_review_with_findings.save(temp_github_dir)
|
||||
|
||||
# Create context with error
|
||||
context = FollowupReviewContext(
|
||||
pr_number=42,
|
||||
previous_review=sample_review_with_findings,
|
||||
previous_commit_sha="abc123",
|
||||
current_commit_sha="def456",
|
||||
error="Failed to compare commits: API rate limit",
|
||||
)
|
||||
|
||||
assert context.error is not None
|
||||
assert "rate limit" in context.error
|
||||
|
||||
# Create error result
|
||||
error_result = PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test-owner/test-repo",
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up failed: {context.error}",
|
||||
overall_status="comment",
|
||||
error=context.error,
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha="def456",
|
||||
)
|
||||
|
||||
assert error_result.success is False
|
||||
assert error_result.error is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E2E Test: Bot Detection Flow
|
||||
# ============================================================================
|
||||
|
||||
class TestBotDetectionE2E:
|
||||
"""Test bot detection end-to-end."""
|
||||
|
||||
def test_full_bot_detection_flow(self, tmp_path):
|
||||
"""Test complete bot detection workflow."""
|
||||
state_dir = tmp_path / "github"
|
||||
state_dir.mkdir(parents=True)
|
||||
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="auto-claude[bot]"):
|
||||
detector = BotDetector(
|
||||
state_dir=state_dir,
|
||||
bot_token="ghp_bot_token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Scenario 1: Human PR, first review
|
||||
pr_data = {"author": {"login": "human-dev"}}
|
||||
commits = [{"author": {"login": "human-dev"}, "oid": "commit_1"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=100,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
assert should_skip is False
|
||||
|
||||
# Mark as reviewed
|
||||
detector.mark_reviewed(100, "commit_1")
|
||||
|
||||
# Scenario 2: Same commit, should skip after cooling off
|
||||
# First, bypass cooling off by setting old timestamp
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
detector.state.last_review_times["100"] = two_min_ago.isoformat()
|
||||
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=100,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
assert should_skip is True
|
||||
assert "Already reviewed" in reason
|
||||
|
||||
# Scenario 3: New commit on same PR
|
||||
new_commits = [{"author": {"login": "human-dev"}, "oid": "commit_2"}]
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=100,
|
||||
pr_data=pr_data,
|
||||
commits=new_commits,
|
||||
)
|
||||
assert should_skip is False # New commit allows review
|
||||
|
||||
# Scenario 4: Bot-authored PR
|
||||
bot_pr = {"author": {"login": "auto-claude[bot]"}}
|
||||
bot_commits = [{"author": {"login": "auto-claude[bot]"}, "oid": "bot_commit"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=200,
|
||||
pr_data=bot_pr,
|
||||
commits=bot_commits,
|
||||
)
|
||||
assert should_skip is True
|
||||
assert "bot" in reason.lower()
|
||||
|
||||
def test_bot_detection_state_persistence(self, tmp_path):
|
||||
"""Test that bot detection state persists across instances."""
|
||||
state_dir = tmp_path / "github"
|
||||
state_dir.mkdir(parents=True)
|
||||
|
||||
# First detector instance
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="bot"):
|
||||
detector1 = BotDetector(state_dir=state_dir, bot_token="token")
|
||||
detector1.mark_reviewed(42, "abc123")
|
||||
|
||||
# Second detector instance (simulating app restart)
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="bot"):
|
||||
detector2 = BotDetector(state_dir=state_dir, bot_token="token")
|
||||
|
||||
# Should see the reviewed commit
|
||||
assert detector2.has_reviewed_commit(42, "abc123") is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E2E Test: Blocker Generation Flow
|
||||
# ============================================================================
|
||||
|
||||
class TestBlockerGenerationE2E:
|
||||
"""Test blocker generation from findings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blockers_generated_correctly(self, temp_github_dir):
|
||||
"""Test that blockers are generated from CRITICAL/HIGH findings."""
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="critical-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Remote Code Execution",
|
||||
description="Critical security flaw",
|
||||
file="src/exec.py",
|
||||
line=1,
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="high-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Memory Leak",
|
||||
description="Resource not freed",
|
||||
file="src/memory.py",
|
||||
line=50,
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="low-1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Naming Convention",
|
||||
description="Variable name not following style",
|
||||
file="src/utils.py",
|
||||
line=10,
|
||||
fixable=True,
|
||||
),
|
||||
]
|
||||
|
||||
# Generate blockers
|
||||
blockers = []
|
||||
for finding in findings:
|
||||
if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Create result with blockers
|
||||
result = PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=findings,
|
||||
summary="Found 3 issues",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha="abc123",
|
||||
)
|
||||
|
||||
# Save and load
|
||||
await result.save(temp_github_dir)
|
||||
loaded = PRReviewResult.load(temp_github_dir, 42)
|
||||
|
||||
assert len(loaded.blockers) == 2
|
||||
assert "security: Remote Code Execution" in loaded.blockers
|
||||
assert "quality: Memory Leak" in loaded.blockers
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E2E Test: Complete Review Lifecycle
|
||||
# ============================================================================
|
||||
|
||||
class TestReviewLifecycleE2E:
|
||||
"""Test the complete review lifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_review_then_followup(self, temp_github_dir):
|
||||
"""Test complete flow: initial review -> post findings -> followup."""
|
||||
# Step 1: Initial review finds issues
|
||||
initial_result = PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[
|
||||
PRReviewFinding(
|
||||
id="issue-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Security Issue",
|
||||
description="Fix this",
|
||||
file="src/auth.py",
|
||||
line=100,
|
||||
fixable=True,
|
||||
),
|
||||
],
|
||||
summary="Found 1 issue",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
reviewed_commit_sha="commit_1",
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
)
|
||||
await initial_result.save(temp_github_dir)
|
||||
|
||||
# Step 2: Post findings to GitHub (simulated)
|
||||
initial_result.has_posted_findings = True
|
||||
initial_result.posted_finding_ids = ["issue-1"]
|
||||
initial_result.posted_at = datetime.now().isoformat()
|
||||
await initial_result.save(temp_github_dir)
|
||||
|
||||
# Verify posted state
|
||||
loaded = PRReviewResult.load(temp_github_dir, 42)
|
||||
assert loaded.has_posted_findings is True
|
||||
|
||||
# Step 3: Contributor fixes the issue, new commit
|
||||
followup_context = FollowupReviewContext(
|
||||
pr_number=42,
|
||||
previous_review=loaded,
|
||||
previous_commit_sha="commit_1",
|
||||
current_commit_sha="commit_2",
|
||||
files_changed_since_review=["src/auth.py"],
|
||||
diff_since_review="- vulnerable_code()\n+ secure_code()",
|
||||
)
|
||||
|
||||
# Step 4: Follow-up review finds issue resolved
|
||||
followup_result = PRReviewResult(
|
||||
pr_number=42,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[],
|
||||
summary="All issues resolved",
|
||||
overall_status="approve",
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
is_followup_review=True,
|
||||
resolved_findings=["issue-1"],
|
||||
unresolved_findings=[],
|
||||
reviewed_commit_sha="commit_2",
|
||||
previous_review_id="42",
|
||||
)
|
||||
await followup_result.save(temp_github_dir)
|
||||
|
||||
# Verify final state
|
||||
final = PRReviewResult.load(temp_github_dir, 42)
|
||||
assert final.is_followup_review is True
|
||||
assert final.verdict == MergeVerdict.READY_TO_MERGE
|
||||
assert "issue-1" in final.resolved_findings
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
Tests for GitHub PR Review System
|
||||
==================================
|
||||
|
||||
Tests the PR review orchestrator and follow-up review functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from dataclasses import asdict
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the backend directory to path
|
||||
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
_github_dir = _backend_dir / "runners" / "github"
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
if str(_backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_backend_dir))
|
||||
|
||||
from models import (
|
||||
PRReviewResult,
|
||||
PRReviewFinding,
|
||||
ReviewSeverity,
|
||||
ReviewCategory,
|
||||
MergeVerdict,
|
||||
FollowupReviewContext,
|
||||
)
|
||||
from bot_detection import BotDetector, BotDetectionState
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def temp_github_dir(tmp_path):
|
||||
"""Create temporary GitHub directory structure."""
|
||||
github_dir = tmp_path / ".auto-claude" / "github"
|
||||
pr_dir = github_dir / "pr"
|
||||
pr_dir.mkdir(parents=True)
|
||||
return github_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_finding():
|
||||
"""Create a sample PR review finding."""
|
||||
return PRReviewFinding(
|
||||
id="finding-001",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection vulnerability",
|
||||
description="User input not sanitized",
|
||||
file="src/db.py",
|
||||
line=42,
|
||||
suggested_fix="Use parameterized queries",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_review_result(sample_finding):
|
||||
"""Create a sample PR review result."""
|
||||
return PRReviewResult(
|
||||
pr_number=123,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[sample_finding],
|
||||
summary="Found 1 security issue",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Security issues must be fixed",
|
||||
reviewed_commit_sha="abc123def456",
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot_detector(tmp_path):
|
||||
"""Create a mock bot detector."""
|
||||
state_dir = tmp_path / "github"
|
||||
state_dir.mkdir(parents=True)
|
||||
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
|
||||
detector = BotDetector(
|
||||
state_dir=state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
return detector
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRReviewResult Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestPRReviewResult:
|
||||
"""Test PRReviewResult model."""
|
||||
|
||||
def test_save_and_load(self, temp_github_dir, sample_review_result):
|
||||
"""Test saving and loading review result."""
|
||||
# Save
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
sample_review_result.save(temp_github_dir)
|
||||
)
|
||||
|
||||
# Verify file exists
|
||||
review_file = temp_github_dir / "pr" / f"review_{sample_review_result.pr_number}.json"
|
||||
assert review_file.exists()
|
||||
|
||||
# Load
|
||||
loaded = PRReviewResult.load(temp_github_dir, sample_review_result.pr_number)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.pr_number == sample_review_result.pr_number
|
||||
assert loaded.success == sample_review_result.success
|
||||
assert len(loaded.findings) == len(sample_review_result.findings)
|
||||
assert loaded.reviewed_commit_sha == sample_review_result.reviewed_commit_sha
|
||||
|
||||
def test_load_nonexistent(self, temp_github_dir):
|
||||
"""Test loading when file doesn't exist."""
|
||||
loaded = PRReviewResult.load(temp_github_dir, 999)
|
||||
assert loaded is None
|
||||
|
||||
def test_to_dict_camelcase(self, sample_review_result):
|
||||
"""Test that to_dict produces correct format."""
|
||||
data = sample_review_result.to_dict()
|
||||
|
||||
# Should use snake_case for JSON serialization
|
||||
assert "pr_number" in data
|
||||
assert "reviewed_commit_sha" in data
|
||||
assert "overall_status" in data
|
||||
assert data["pr_number"] == 123
|
||||
|
||||
def test_from_dict_handles_snake_case(self, sample_review_result):
|
||||
"""Test that from_dict handles snake_case input."""
|
||||
data = {
|
||||
"pr_number": 456,
|
||||
"repo": "test/repo",
|
||||
"success": True,
|
||||
"findings": [],
|
||||
"summary": "Test summary",
|
||||
"overall_status": "approve",
|
||||
"reviewed_commit_sha": "xyz789",
|
||||
"reviewed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
result = PRReviewResult.from_dict(data)
|
||||
|
||||
assert result.pr_number == 456
|
||||
assert result.reviewed_commit_sha == "xyz789"
|
||||
|
||||
|
||||
class TestPRReviewFinding:
|
||||
"""Test PRReviewFinding model."""
|
||||
|
||||
def test_finding_serialization(self, sample_finding):
|
||||
"""Test finding serialization to dict."""
|
||||
data = sample_finding.to_dict()
|
||||
|
||||
assert data["id"] == "finding-001"
|
||||
assert data["severity"] == "high"
|
||||
assert data["category"] == "security"
|
||||
assert data["file"] == "src/db.py"
|
||||
assert data["line"] == 42
|
||||
|
||||
def test_finding_deserialization(self):
|
||||
"""Test finding deserialization from dict."""
|
||||
data = {
|
||||
"id": "finding-002",
|
||||
"severity": "critical",
|
||||
"category": "quality",
|
||||
"title": "Memory leak",
|
||||
"description": "Resource not released",
|
||||
"file": "src/memory.py",
|
||||
"line": 100,
|
||||
"suggested_fix": "Add cleanup code",
|
||||
"fixable": True,
|
||||
}
|
||||
|
||||
finding = PRReviewFinding.from_dict(data)
|
||||
|
||||
assert finding.id == "finding-002"
|
||||
assert finding.severity == ReviewSeverity.CRITICAL
|
||||
assert finding.category == ReviewCategory.QUALITY
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Follow-up Review Context Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestFollowupReviewContext:
|
||||
"""Test FollowupReviewContext model."""
|
||||
|
||||
def test_context_with_changes(self, sample_review_result, sample_finding):
|
||||
"""Test follow-up context with file changes."""
|
||||
context = FollowupReviewContext(
|
||||
pr_number=123,
|
||||
previous_review=sample_review_result,
|
||||
previous_commit_sha="abc123",
|
||||
current_commit_sha="def456",
|
||||
files_changed_since_review=["src/db.py", "src/api.py"],
|
||||
diff_since_review="diff content here",
|
||||
)
|
||||
|
||||
assert context.pr_number == 123
|
||||
assert context.previous_commit_sha == "abc123"
|
||||
assert context.current_commit_sha == "def456"
|
||||
assert len(context.files_changed_since_review) == 2
|
||||
assert context.error is None
|
||||
|
||||
def test_context_with_error(self, sample_review_result):
|
||||
"""Test follow-up context with error flag."""
|
||||
context = FollowupReviewContext(
|
||||
pr_number=123,
|
||||
previous_review=sample_review_result,
|
||||
previous_commit_sha="abc123",
|
||||
current_commit_sha="def456",
|
||||
error="Failed to compare commits: API error",
|
||||
)
|
||||
|
||||
assert context.error is not None
|
||||
assert "Failed to compare commits" in context.error
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bot Detection Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestBotDetectionIntegration:
|
||||
"""Test bot detection integration with review flow."""
|
||||
|
||||
def test_already_reviewed_returns_skip(self, mock_bot_detector):
|
||||
"""Test that already reviewed commit returns skip."""
|
||||
from datetime import timedelta
|
||||
|
||||
# Mark commit as reviewed
|
||||
mock_bot_detector.mark_reviewed(123, "abc123def456")
|
||||
|
||||
# Set last review time to 2 minutes ago to bypass cooling off (1 minute)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123def456"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "Already reviewed" in reason
|
||||
|
||||
def test_new_commit_allows_review(self, mock_bot_detector):
|
||||
"""Test that new commit allows review."""
|
||||
from datetime import timedelta
|
||||
|
||||
# Mark old commit as reviewed
|
||||
mock_bot_detector.mark_reviewed(123, "old_commit_sha")
|
||||
|
||||
# Set last review time to 2 minutes ago to bypass cooling off (1 minute)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
# New commit - not yet reviewed
|
||||
commits = [{"author": {"login": "alice"}, "oid": "new_commit_sha"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Orchestrator Skip Logic Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestOrchestratorSkipLogic:
|
||||
"""Test orchestrator behavior when bot detection skips."""
|
||||
|
||||
def test_skip_returns_existing_review(self, temp_github_dir, sample_review_result):
|
||||
"""Test that skipping 'Already reviewed' returns existing review."""
|
||||
import asyncio
|
||||
|
||||
# Save existing review
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
sample_review_result.save(temp_github_dir)
|
||||
)
|
||||
|
||||
# Simulate the orchestrator logic for "Already reviewed" skip
|
||||
skip_reason = "Already reviewed commit abc123"
|
||||
|
||||
# This is what the orchestrator should do:
|
||||
if "Already reviewed" in skip_reason:
|
||||
existing_review = PRReviewResult.load(temp_github_dir, 123)
|
||||
assert existing_review is not None
|
||||
assert existing_review.success is True
|
||||
assert len(existing_review.findings) == 1
|
||||
# Existing review should be returned, not overwritten
|
||||
|
||||
def test_skip_bot_pr_creates_skip_result(self, temp_github_dir):
|
||||
"""Test that skipping bot PR creates skip result."""
|
||||
skip_reason = "PR is authored by bot user test-bot"
|
||||
|
||||
# For non-"Already reviewed" skips, create skip result
|
||||
if "Already reviewed" not in skip_reason:
|
||||
result = PRReviewResult(
|
||||
pr_number=456,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[],
|
||||
summary=f"Skipped review: {skip_reason}",
|
||||
overall_status="comment",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.findings) == 0
|
||||
assert "bot user" in result.summary
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Follow-up Review Logic Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestFollowupReviewLogic:
|
||||
"""Test follow-up review resolution logic."""
|
||||
|
||||
def test_finding_marked_resolved_when_file_changed(self):
|
||||
"""Test that findings are resolved when their files are changed."""
|
||||
# Finding in src/db.py at line 42
|
||||
finding = PRReviewFinding(
|
||||
id="finding-001",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection",
|
||||
description="Issue description",
|
||||
file="src/db.py",
|
||||
line=42,
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
# File was changed
|
||||
changed_files = ["src/db.py", "src/api.py"]
|
||||
|
||||
# Simulate resolution check
|
||||
file_was_changed = finding.file in changed_files
|
||||
assert file_was_changed is True
|
||||
|
||||
def test_finding_unresolved_when_file_not_changed(self):
|
||||
"""Test that findings are NOT resolved when files unchanged."""
|
||||
finding = PRReviewFinding(
|
||||
id="finding-001",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection",
|
||||
description="Issue description",
|
||||
file="src/db.py",
|
||||
line=42,
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
# Different files changed
|
||||
changed_files = ["src/api.py", "src/utils.py"]
|
||||
|
||||
file_was_changed = finding.file in changed_files
|
||||
assert file_was_changed is False
|
||||
|
||||
def test_followup_result_tracks_resolution(self, sample_finding):
|
||||
"""Test that follow-up result correctly tracks resolution status."""
|
||||
result = PRReviewResult(
|
||||
pr_number=123,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[], # No new findings
|
||||
summary="All issues resolved",
|
||||
overall_status="approve",
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
is_followup_review=True,
|
||||
resolved_findings=["finding-001"],
|
||||
unresolved_findings=[],
|
||||
new_findings_since_last_review=[],
|
||||
)
|
||||
|
||||
assert result.is_followup_review is True
|
||||
assert len(result.resolved_findings) == 1
|
||||
assert len(result.unresolved_findings) == 0
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Posted Findings Tracking Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestPostedFindingsTracking:
|
||||
"""Test posted findings tracking for follow-up eligibility."""
|
||||
|
||||
def test_has_posted_findings_flag(self, sample_review_result):
|
||||
"""Test has_posted_findings flag tracking."""
|
||||
# Initially not posted
|
||||
assert sample_review_result.has_posted_findings is False
|
||||
|
||||
# After posting
|
||||
sample_review_result.has_posted_findings = True
|
||||
sample_review_result.posted_finding_ids = ["finding-001"]
|
||||
sample_review_result.posted_at = datetime.now().isoformat()
|
||||
|
||||
assert sample_review_result.has_posted_findings is True
|
||||
assert len(sample_review_result.posted_finding_ids) == 1
|
||||
|
||||
def test_posted_findings_serialization(self, temp_github_dir, sample_review_result):
|
||||
"""Test that posted findings are serialized correctly."""
|
||||
import asyncio
|
||||
|
||||
# Set posted findings
|
||||
sample_review_result.has_posted_findings = True
|
||||
sample_review_result.posted_finding_ids = ["finding-001"]
|
||||
sample_review_result.posted_at = "2025-01-01T10:00:00"
|
||||
|
||||
# Save
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
sample_review_result.save(temp_github_dir)
|
||||
)
|
||||
|
||||
# Load and verify
|
||||
loaded = PRReviewResult.load(temp_github_dir, sample_review_result.pr_number)
|
||||
|
||||
assert loaded.has_posted_findings is True
|
||||
assert loaded.posted_finding_ids == ["finding-001"]
|
||||
assert loaded.posted_at == "2025-01-01T10:00:00"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Handling Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling in review flow."""
|
||||
|
||||
def test_context_gathering_error_propagates(self, sample_review_result):
|
||||
"""Test that context gathering errors are propagated."""
|
||||
context = FollowupReviewContext(
|
||||
pr_number=123,
|
||||
previous_review=sample_review_result,
|
||||
previous_commit_sha="abc123",
|
||||
current_commit_sha="def456",
|
||||
error="Failed to compare commits: 404 Not Found",
|
||||
)
|
||||
|
||||
# Orchestrator should check for error and handle appropriately
|
||||
if context.error:
|
||||
result = PRReviewResult(
|
||||
pr_number=123,
|
||||
repo="test/repo",
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up review failed: {context.error}",
|
||||
overall_status="comment",
|
||||
error=context.error,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "404" in result.error
|
||||
|
||||
def test_invalid_finding_data_handled(self):
|
||||
"""Test that invalid finding data is handled gracefully."""
|
||||
invalid_data = {
|
||||
"id": "finding-001",
|
||||
"severity": "invalid_severity", # Invalid
|
||||
"category": "security",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
"line": 1,
|
||||
}
|
||||
|
||||
# Should not crash, should use default or handle gracefully
|
||||
try:
|
||||
finding = PRReviewFinding.from_dict(invalid_data)
|
||||
# If it doesn't raise, verify it handled the invalid data somehow
|
||||
assert finding.id == "finding-001"
|
||||
except (ValueError, KeyError):
|
||||
# Expected for invalid severity
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Blocker Generation Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestBlockerGeneration:
|
||||
"""Test blocker generation from findings."""
|
||||
|
||||
def test_blockers_from_critical_findings(self):
|
||||
"""Test that blockers are generated from CRITICAL findings."""
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Critical Security Issue",
|
||||
description="Desc",
|
||||
file="a.py",
|
||||
line=1,
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="2",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Style Issue",
|
||||
description="Desc",
|
||||
file="b.py",
|
||||
line=2,
|
||||
fixable=True,
|
||||
),
|
||||
]
|
||||
|
||||
# Generate blockers from CRITICAL/HIGH
|
||||
blockers = []
|
||||
for finding in findings:
|
||||
if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
assert len(blockers) == 1
|
||||
assert "security: Critical Security Issue" in blockers
|
||||
|
||||
def test_blockers_from_high_findings(self):
|
||||
"""Test that blockers are generated from HIGH findings."""
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Memory Leak",
|
||||
description="Desc",
|
||||
file="a.py",
|
||||
line=1,
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="2",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Code Smell",
|
||||
description="Desc",
|
||||
file="b.py",
|
||||
line=2,
|
||||
fixable=True,
|
||||
),
|
||||
]
|
||||
|
||||
blockers = []
|
||||
for finding in findings:
|
||||
if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
assert len(blockers) == 1
|
||||
assert "quality: Memory Leak" in blockers
|
||||
|
||||
def test_no_blockers_for_low_severity(self):
|
||||
"""Test that no blockers for LOW/MEDIUM findings."""
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Style Issue",
|
||||
description="Desc",
|
||||
file="a.py",
|
||||
line=1,
|
||||
fixable=True,
|
||||
),
|
||||
PRReviewFinding(
|
||||
id="2",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.DOCS,
|
||||
title="Missing Docs",
|
||||
description="Desc",
|
||||
file="b.py",
|
||||
line=2,
|
||||
fixable=True,
|
||||
),
|
||||
]
|
||||
|
||||
blockers = []
|
||||
for finding in findings:
|
||||
if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
assert len(blockers) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user