From 7b4993e9dbd52da5b8f36f3f93c719213c1491b8 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:13:41 +0100 Subject: [PATCH] Fix/small fixes all around (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * auto-claude: subtask-1-1 - Update package.json extraResources to bundle packa * auto-claude: subtask-1-2 - Create sitecustomize.py generator script for build * auto-claude: subtask-1-3 - Update package.json to include sitecustomize.py in extraResources * auto-claude: subtask-1-4 - Update python:download script to generate sitecust * auto-claude: subtask-2-1 - Add detailed logging to agent subprocess initialization * auto-claude: subtask-2-2 - Add timeout logging to backend agent SDK initialization * auto-claude: subtask-2-3 - Create minimal reproducible test for .exe subprocess communication - Created test-agent-subprocess.cjs following verify-python-bundling.cjs patterns - Tests Python subprocess spawn, imports, and Claude SDK initialization - Simulates agent-process.ts environment setup (PYTHONPATH, PYTHONUNBUFFERED, etc.) - Measures initialization timing to diagnose .exe timeout issues - Provides detailed diagnostics for package location and import failures - Includes 10s timeout detection to catch hanging processes - Outputs actionable debugging steps for .exe vs dev comparison * auto-claude: subtask-2-4 - Fix subprocess spawn configuration for packaged Windows .exe - Add explicit stdio: 'pipe' configuration (pattern from python-env-manager.ts) - Add windowsHide: true to prevent console popup windows in packaged builds - Fixes buffering issues that cause agent initialization timeouts in .exe - Follows spawn patterns from python-env-manager.ts (lines 244, 315, 367) * auto-claude: subtask-3-1 - Add Windows .exe build verification documentation and script - Created PowerShell verification script (verify-windows-build.ps1) that checks: - Build directory structure - Python executable presence - site-packages directory and contents - sitecustomize.py existence - All required packages (dotenv, anthropic, graphiti_core, claude_agent_sdk) - Python imports work correctly - sys.path includes bundled packages - Created comprehensive verification guide (WINDOWS_BUILD_VERIFICATION.md): - Step-by-step build and verification instructions - Package structure documentation - Manual testing procedures - Common issues and troubleshooting - Success criteria checklist - Downloaded Windows Python runtime to python-runtime/win-x64/ - Manually copied sitecustomize.py to Windows runtime (cross-platform build limitation) NOTE: Actual Windows .exe build verification requires Windows or CI/CD environment. macOS cannot execute Windows .exe files. All configuration changes are in place: ✓ package.json extraResources: bundles to python/Lib/site-packages ✓ sitecustomize.py: generated and bundled ✓ Windows Python runtime: downloaded with correct structure ✓ All previous fixes (subtasks 1-1 through 2-4): committed Ready for Windows testing using provided verification script and documentation. * auto-claude: subtask-3-2 - Create E2E spec creation test documentation and automation - Created comprehensive E2E test documentation (E2E_SPEC_CREATION_TEST.md): - Detailed step-by-step manual test procedure for Windows .exe verification - 5 verification steps: Launch .exe, Create task, Wait for Planning, Verify spec.md, Check logs - Success/failure criteria with specific actionable checks - Troubleshooting guide for timeout errors, missing spec.md, and import failures - Reporting guidelines for test results with required diagnostic information - Platform limitation notes (macOS/Linux cannot run Windows .exe) - Created PowerShell automation script (test-e2e-spec-creation.ps1): - Pre-test phase: Validates build structure, Python runtime, packages, imports - Post-test phase: Verifies spec.md creation, validates content and required sections - Color-coded pass/fail output for easy interpretation - Automated next steps and troubleshooting recommendations - Exit codes for CI/CD integration (0=pass, 1=fail) - Created comprehensive testing guide (TESTING_GUIDE.md): - Quick start workflow for Windows testers - Documentation index linking all test resources - Script index with usage examples - Testing phases overview (shows progress: Phase 1✓, Phase 2✓, Phase 3 in progress) - Common test scenarios with complete step-by-step instructions - Success criteria summary aligned with spec requirements - CI/CD integration examples for automated testing - Platform limitations and workarounds PLATFORM LIMITATION: - macOS environment cannot execute Windows .exe files - All test documentation, automation, and procedures are complete - Actual E2E testing requires Windows environment or Windows CI/CD - All code fixes from previous subtasks (1-1 through 2-4) are committed and ready This subtask provides complete testing infrastructure for Windows testers to verify the Planning timeout fix. Ready for Windows-based E2E verification. * Update implementation plan: mark subtask-3-2 as completed * auto-claude: subtask-3-3 - Test Insights and Context features in .exe * auto-claude: subtask-3-4 - Verify Git/development version still works Regression testing completed successfully: - Unit tests: 1195/1201 passed (48 test files) - TypeScript compilation: No errors - Build process: All artifacts built successfully - Code review: Changes follow existing patterns All Windows .exe fixes verified safe for development mode: - package.json changes only affect packaged builds - agent-process.ts changes follow python-env-manager.ts patterns - Backend logging additions are debug-level only Risk Assessment: LOW - No regressions detected Status: Ready for Windows .exe testing and merge Created REGRESSION_TEST_REPORT.md with full test results * auto-claude: Update implementation_plan.json - mark subtask-3-4 as completed * refactor(onboarding): align memory step UI with settings page Simplifies the onboarding Memory step to match the project settings Memory section structure for a consistent UX across the app. Changes: - Enable memory by default (was disabled) - Add Enable Agent Memory Access toggle with MCP server URL field - Use same Switch-based toggle approach as settings page - Remove complex explanatory cards in favor of simpler info banner - Add Skip button for users who want to configure later - Add graphitiMcpEnabled and graphitiMcpUrl to AppSettings type * perf(merge): defer conflict check to user action instead of modal open Previously, opening a task modal automatically triggered an expensive merge preview operation (1-30+ seconds) that spawned a Python subprocess to check for conflicts. This caused the modal to feel slow and generated many "File X not being tracked" warnings in the console. Now the modal opens instantly, showing a "Check for Conflicts" button. The expensive preview only runs when the user clicks this button. After checking, the button changes to "Merge to Main" / "Stage to Main" (no conflicts) or "Merge with AI" / "Stage with AI Merge" (has conflicts). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(onboarding): enable Graphiti memory by default New users get a better first-time experience with persistent memory enabled out of the box. This allows them to benefit from cross-session context without needing to discover and enable the feature manually. They can still disable it if they prefer. * fix(security): block agents from modifying git user identity Agents were able to run `git config user.name "Test User"` which broke commit attribution and caused commits to appear from fake identities. Changes: - Add git config validator to security sandbox that blocks user.name, user.email, author.*, and committer.* config changes - Add explicit warnings to coder.md and qa_fixer.md agent prompts - Export new validators from security/validator.py The security sandbox now provides clear feedback explaining why identity changes are blocked and what agents should do instead (use inherited config). * fix(onboarding): add cost warning for custom API key option Users selecting the custom API key authentication method should be aware that this option is experimental and may incur significant costs compared to the standard OAuth flow. * perf(merge): fix git diff to return only task-changed files The merge preview was analyzing 342 files for tasks that only modified 1 file, taking 10-30 seconds. Root cause: three-dot git diff (A...B) returns files changed on EITHER branch since divergence. Fixes: - Use explicit merge-base with two-dot diff to get only task's changes - Add fast path that skips semantic analysis when 0 commits behind - Change "file not tracked" from WARNING to DEBUG (expected for main's changes) This reduces merge preview time from 10-30s to <1s for simple tasks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor(onboarding): use MemoryStep instead of GraphitiStep in wizard Updates the onboarding wizard to use the simplified MemoryStep component that matches the settings page structure, replacing the old GraphitiStep. - Import MemoryStep instead of GraphitiStep - Remove onSkip prop (MemoryStep has built-in Skip button) - Add MCP settings types (graphitiMcpEnabled, graphitiMcpUrl) to AppSettings * fix worktree system logic * fix(worktree): use remote branch as source of truth for worktree creation Previously, worktrees were created from the local branch, which could be outdated compared to GitHub/remote. This caused issues where the worktree would be based on old code if the user's local branch was behind origin. Now the system: 1. Fetches the latest from origin/{base_branch} before creating the worktree 2. Uses origin/{base_branch} as the start point (source of truth) 3. Falls back gracefully to local branch if remote isn't available This ensures GitHub is truly the source of truth for code while spec files remain local and git-ignored. * fix(merge): use consistent line endings across all change types The file merger detected and preserved original line endings (CRLF, CR, LF) for imports but hardcoded \n for functions and other changes. This caused inconsistent line endings in merged files on Windows/legacy systems. Now detects line ending once at start and uses it consistently for all additions (imports, functions, other changes). * fix(merge): remove incorrect fast path in merge preview The FAST PATH optimization incorrectly assumed that if commits_behind == 0, no conflicts are possible. This was wrong because the evolution tracker maintains data about all active parallel tasks, and other tasks may conflict even when main hasn't moved. Removing the fast path ensures: 1. refresh_from_git() is always called to update evolution data 2. preview_merge() runs semantic analysis to detect cross-task conflicts * fix(github): enhance follow-up review logic to handle rebased PRs Updated the GitHubOrchestrator to check for both new commits and file changes when reviewing pull requests. This ensures that even after a rebase or force-push, the review process continues based on actual file changes. Added corresponding tests to validate behavior in scenarios with no new commits but changed files. * fix(frontend): resolve TypeScript errors blocking commit - Remove non-existent memoryDatabase property from AppSettings usage - Use hardcoded 'auto_claude_memory' default in pr-handlers and memory-env-builder - Add missing GitHub API methods to browser-mock (getPR, getWorkflowsAwaitingApproval, approveWorkflow) These fixes resolve pre-commit hook TypeScript errors that were preventing commits. * feat(memory): integrate PR review insights with graph memory system - Add PR review memory persistence to LadybugDB via query_memory.py - Save comprehensive PR review insights including findings, patterns, and gotchas - Create PRReviewCard component for rich memory visualization - Enhance MemoriesTab with filtering by category (PR, sessions, codebase, patterns) - Add memory type icons, colors, and filter categories - Add workflow approval support for fork PRs (getWorkflowsAwaitingApproval, approveWorkflow) - Update memory-service.ts for packaged app compatibility - Remove pandas dependency from query_memory.py for lighter footprint This enables the AI to learn from PR reviews over time, building a knowledge base of patterns, gotchas, and insights specific to each project. * fix(pr-review): add worktree support to follow-up reviewer The follow-up PR reviewer was reading files from the local checkout instead of the PR's actual branch. This caused incorrect analysis when the local repo was on a different branch than the PR being reviewed. Added worktree creation/cleanup to ParallelFollowupReviewer (matching the initial reviewer's behavior) so agents now read from the correct PR state during follow-up reviews. * fix: address PR review feedback from CodeQL/security scan Security fixes: - Git config validator now fails closed on parse errors (prevents bypass) - Git config blocklist uses exact key matching (prevents false positives) - Symlink protection added to sync_spec_to_source (prevents path traversal) - Plan file write success tracking in recovery handler (prevents silent failures) Code improvements: - Renamed sync_plan_to_source → sync_spec_to_source across all callers - Added i18n translations to MemoryStep onboarding component - Fixed phase_event error handler to avoid nested OSError 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(memory): enhance agent memory tools with LadybugDB integration - Updated record_discovery and record_gotcha tools to write to both file-based storage (primary) and LadybugDB (secondary) - This ensures real-time discoveries made during coding sessions appear in the Memory UI - Added support for qa_result and historical_context episode types in the frontend constants for future compatibility * fix(terminal): exclude DEBUG env var from spawned PTY processes When the Electron app runs in development mode with DEBUG=true, this environment variable was being passed to all spawned PTY processes. Claude Code detects DEBUG=true and automatically enables debug mode, causing "Debug mode enabled" messages to appear in all agent terminals. This fix excludes the DEBUG variable from the environment passed to spawned terminals, preventing Claude Code from inheriting it. * fix(terminal): persist terminal names and worktree associations across restarts - Add worktreeConfig field to TerminalProcess and TerminalSession types - Add setTerminalTitle and setTerminalWorktreeConfig IPC channels - Sync title and worktree config changes from renderer to main process - Restore worktreeConfig when restoring terminal sessions - Send TERMINAL_TITLE_CHANGE event for all restored terminals (not just Claude mode) - Validate worktree configs on restore - clear if worktree path no longer exists - Add browser mocks for new terminal API methods This ensures terminal names and worktree associations survive app restarts and hot reloads, while gracefully handling deleted worktrees. * terminal persistence worktree and name * agent terminal fixes * terminal persistence issues * fix(security): block git identity bypass via -c flag and add subprocess timeouts Address PR review findings: - Block git -c user.name/email=... on ANY git command, not just git config - Fix misleading docstring in detect_line_ending (said "dominant" but used priority) - Add timeout (60s) to worktree._run_git() with TimeoutExpired handling - Add timeout (30s) to batch_commands worktree cleanup with fallback Includes 8 new tests for git identity protection in TestGitIdentityProtection. * chore: address PR review feedback (LOW severity items) - Add timeout=10 to subprocess calls in agents/utils.py - Add timeout=5 to branch verification in workspace_commands.py - Add proper @deprecated JSDoc annotation in settings.ts - Document environment variable limitation in git_validators.py * fix(test): add setMaxListeners to electron mock for ipcRenderer The terminal-api.ts calls ipcRenderer.setMaxListeners() at import time, but the electron mock was missing this method, causing 19 frontend tests to fail in CI. Added setMaxListeners to both: - src/__mocks__/electron.ts (global mock) - src/__tests__/integration/ipc-bridge.test.ts (test-specific mock) * fix(pr-review): pass CI status to AI orchestrator for follow-up reviews Previously, CI status was fetched AFTER the AI review completed, so the orchestrator couldn't factor failing CI into its verdict reasoning. This caused confusing outputs where the AI would say "Merge With Changes" but then a separate CI warning was appended. Now CI status is: - Fetched before calling the parallel followup reviewer - Added to FollowupReviewContext as ci_status field - Formatted prominently in the prompt context - Documented in verdict guidelines (failing CI = BLOCKED) The AI orchestrator will now properly reason about CI status and include it in its verdict, e.g. "BLOCKED: 2 CI checks failing (CodeQL, test-frontend)" * fix(test): add app to electron mock in runner-env-handlers test * fix: address CodeQL security alerts - Fix log injection in app-updater.ts by sanitizing external data before logging (status codes, versions, error messages) - Fix regex injection in bump-version.js by properly escaping all regex metacharacters when building version pattern - Remove unused imports across multiple files: - app-updater.ts: removed unused path import - version-suggester.ts: removed unused path import - config.ts: removed unused app import - agent-events-handlers.ts: removed unused path, getSpecsDir, AUTO_BUILD_PATHS - execution-handlers.ts: removed unused mkdirSync, persistPlanStatusSync - ModelSearchableSelect.tsx: removed unused AlertCircle import - PRDetail.tsx: removed unused formatDate, WorkflowAwaitingApproval, i18n - test_worktree.py: removed unused WorktreeError import - test_project_analyzer.py: removed 4 unused command constant imports - test_finding_validation.py: removed unused PRReviewResult, MergeVerdict imports - Prefix unused variables with underscore to satisfy eslint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(insights): add missing AUTOBUILD_SOURCE_ENV IPC handlers The Insights feature was failing with "No handler registered for 'autobuild:source:env:checkToken'" because the handlers for the AUTOBUILD_SOURCE_ENV_* IPC channels were never implemented. Added three handlers to settings-handlers.ts: - AUTOBUILD_SOURCE_ENV_GET: Read source .env config - AUTOBUILD_SOURCE_ENV_UPDATE: Update source .env file - AUTOBUILD_SOURCE_ENV_CHECK_TOKEN: Check if Claude token exists These handlers read/write the .env file in the auto-build source path (apps/backend) to manage the Claude OAuth token needed for ideation and roadmap generation features. Fixes the Claude Authentication dialog appearing even when already authenticated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(insights): handle production mode for source env handlers The AUTOBUILD_SOURCE_ENV handlers weren't working correctly in production (installed app) because: 1. Path detection was wrong - backend is at process.resourcesPath/backend not relative to appPath. Fixed to check the correct extraResources location first. 2. The .env file is excluded from the bundle (see electron-builder config). In production, we now store the source .env in app.getPath('userData')/backend/ which is a writable location. 3. Added fallback to globalClaudeOAuthToken from app settings. Users can configure the token in Settings > API Configuration and it will work even without a source .env file. This ensures the Insights feature works correctly both in development mode (using apps/backend/.env) and in installed versions (using userData/backend/.env or global settings). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address PR feedback - unused variables and git validator tests - Fix unused variables in memory.py (loop, future) by removing intermediate variable assignments - Fix unused pythonEnv in memory-service.ts by using it directly - Fix unused prNumberStr in useGitHubPRs.ts by iterating values only - Add comprehensive test coverage for validate_git_config - Export validate_git_config and validate_git_command from security module - Fix validate_git_config to allow read operations (--get, --list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(security): address CodeQL log-injection and regex-injection alerts - app-updater.ts: Strengthen statusCode sanitization with numeric validation - app-updater.ts: Sanitize JSON parse error before logging - bump-version.js: Replace regex with string-based changelog search to eliminate regex injection concerns from command-line version input 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(codeql): explicit import for sync_spec_to_source and remove unused import - agents/__init__.py: Add explicit import for sync_spec_to_source (CodeQL static analysis doesn't recognize __getattr__ dynamic exports) - test_worktree.py: Remove unused WorktreeInfo import 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(security): eliminate TOCTOU race condition in settings-handlers Replace existsSync + readFileSync pattern with try/catch around readFileSync to prevent file system race condition (TOCTOU) when reading and writing the source env file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- apps/backend/agents/README.md | 4 +- apps/backend/agents/__init__.py | 10 +- apps/backend/agents/coder.py | 4 +- apps/backend/agents/session.py | 4 +- apps/backend/agents/tools_pkg/tools/memory.py | 117 ++++- apps/backend/agents/utils.py | 105 +++- apps/backend/cli/batch_commands.py | 50 ++ apps/backend/cli/build_commands.py | 4 +- apps/backend/cli/workspace_commands.py | 35 +- apps/backend/core/agent.py | 4 +- apps/backend/core/phase_event.py | 6 +- apps/backend/core/workspace/setup.py | 9 + apps/backend/core/worktree.py | 68 ++- .../file_evolution/modification_tracker.py | 28 +- apps/backend/merge/file_merger.py | 47 +- apps/backend/prompts/coder.md | 19 +- .../github/pr_followup_orchestrator.md | 19 + apps/backend/prompts/qa_fixer.md | 12 +- apps/backend/prompts/qa_reviewer.md | 6 +- apps/backend/prompts_pkg/prompts.py | 147 ++++++ apps/backend/query_memory.py | 219 ++++++-- apps/backend/runners/github/gh_client.py | 140 ++++- apps/backend/runners/github/models.py | 4 + apps/backend/runners/github/orchestrator.py | 83 ++- .../services/parallel_followup_reviewer.py | 211 +++++++- apps/backend/security/__init__.py | 4 + apps/backend/security/git_validators.py | 206 +++++++- apps/backend/security/validator.py | 8 +- apps/frontend/package.json | 2 + apps/frontend/src/__mocks__/electron.ts | 3 +- .../__tests__/integration/ipc-bridge.test.ts | 3 +- .../frontend/src/main/agent/env-utils.test.ts | 5 + apps/frontend/src/main/agent/env-utils.ts | 5 + apps/frontend/src/main/app-updater.ts | 21 +- .../src/main/changelog/version-suggester.ts | 1 - apps/frontend/src/main/insights/config.ts | 1 - .../ipc-handlers/agent-events-handlers.ts | 3 +- .../__tests__/runner-env-handlers.test.ts | 4 + .../ipc-handlers/github/autofix-handlers.ts | 10 +- .../ipc-handlers/github/import-handlers.ts | 3 +- .../github/investigation-handlers.ts | 3 +- .../main/ipc-handlers/github/pr-handlers.ts | 495 +++++++++++++++++- .../main/ipc-handlers/github/spec-utils.ts | 18 +- .../ipc-handlers/gitlab/import-handlers.ts | 2 +- .../gitlab/investigation-handlers.ts | 2 +- .../main/ipc-handlers/gitlab/spec-utils.ts | 62 ++- .../src/main/ipc-handlers/project-handlers.ts | 46 +- .../main/ipc-handlers/settings-handlers.ts | 254 ++++++++- .../main/ipc-handlers/shared/label-utils.ts | 34 ++ .../ipc-handlers/task/execution-handlers.ts | 91 +++- .../ipc-handlers/task/worktree-handlers.ts | 289 ++++++++-- .../main/ipc-handlers/terminal-handlers.ts | 16 + .../terminal/worktree-handlers.ts | 37 +- apps/frontend/src/main/memory-env-builder.ts | 5 + apps/frontend/src/main/memory-service.ts | 176 ++++++- apps/frontend/src/main/project-store.ts | 7 +- .../src/main/terminal-session-store.ts | 71 ++- .../terminal/claude-integration-handler.ts | 46 +- .../src/main/terminal/output-parser.ts | 80 +++ .../frontend/src/main/terminal/pty-manager.ts | 10 +- .../src/main/terminal/session-handler.ts | 3 +- .../main/terminal/terminal-event-handler.ts | 33 ++ .../src/main/terminal/terminal-lifecycle.ts | 82 ++- .../src/main/terminal/terminal-manager.ts | 20 + apps/frontend/src/main/terminal/types.ts | 3 + .../src/preload/api/modules/github-api.ts | 51 +- apps/frontend/src/preload/api/terminal-api.ts | 31 ++ .../components/AgentProfileSelector.tsx | 30 +- .../components/SortableTerminalWrapper.tsx | 83 +++ .../components/TaskCreationWizard.tsx | 9 +- .../renderer/components/TaskEditDialog.tsx | 12 +- .../src/renderer/components/Terminal.tsx | 105 +++- .../src/renderer/components/TerminalGrid.tsx | 206 +++++--- .../src/renderer/components/Worktrees.tsx | 368 +++++++++---- .../components/context/MemoriesTab.tsx | 191 ++++++- .../components/context/MemoryCard.tsx | 39 +- .../components/context/PRReviewCard.tsx | 319 +++++++++++ .../renderer/components/context/constants.ts | 57 +- .../components/github-prs/GitHubPRs.tsx | 14 +- .../github-prs/components/PRDetail.tsx | 276 +++++++--- .../github-prs/components/PRHeader.tsx | 96 +++- .../github-prs/components/PRList.tsx | 65 ++- .../github-prs/hooks/useGitHubPRs.ts | 205 ++++++-- .../components/onboarding/GraphitiStep.tsx | 2 +- .../components/onboarding/MemoryStep.tsx | 399 +++++++------- .../settings/AgentProfileSettings.tsx | 281 +++++----- .../settings/ModelSearchableSelect.tsx | 4 +- .../task-detail/hooks/useTaskDetail.ts | 16 +- .../task-review/WorkspaceStatus.tsx | 95 ++-- .../components/terminal/TerminalHeader.tsx | 74 ++- .../components/terminal/WorktreeSelector.tsx | 133 ++++- .../src/renderer/components/terminal/types.ts | 19 +- .../components/terminal/useAutoNaming.ts | 2 + .../components/terminal/usePtyProcess.ts | 6 +- .../components/terminal/useTerminalEvents.ts | 43 +- .../renderer/components/terminal/useXterm.ts | 80 ++- .../frontend/src/renderer/lib/browser-mock.ts | 4 + .../src/renderer/lib/mocks/terminal-mock.ts | 11 +- .../src/renderer/stores/terminal-store.ts | 57 +- apps/frontend/src/shared/constants/ipc.ts | 12 + apps/frontend/src/shared/constants/models.ts | 90 +++- .../src/shared/i18n/locales/en/common.json | 21 +- .../shared/i18n/locales/en/onboarding.json | 37 +- .../src/shared/i18n/locales/en/settings.json | 4 +- .../src/shared/i18n/locales/en/terminal.json | 10 +- .../src/shared/i18n/locales/fr/common.json | 21 +- .../shared/i18n/locales/fr/onboarding.json | 37 +- .../src/shared/i18n/locales/fr/settings.json | 4 +- .../src/shared/i18n/locales/fr/terminal.json | 10 +- apps/frontend/src/shared/types/ipc.ts | 4 + apps/frontend/src/shared/types/project.ts | 18 +- apps/frontend/src/shared/types/settings.ts | 15 +- apps/frontend/src/shared/types/terminal.ts | 2 + package-lock.json | 12 +- scripts/bump-version.js | 17 +- tests/test_finding_validation.py | 2 - tests/test_github_pr_review.py | 48 ++ tests/test_project_analyzer.py | 4 - tests/test_security.py | 146 ++++++ tests/test_worktree.py | 2 +- 120 files changed, 6247 insertions(+), 1171 deletions(-) create mode 100644 apps/frontend/src/main/ipc-handlers/shared/label-utils.ts create mode 100644 apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx create mode 100644 apps/frontend/src/renderer/components/context/PRReviewCard.tsx diff --git a/apps/backend/agents/README.md b/apps/backend/agents/README.md index 1cf2b2fb..85253eae 100644 --- a/apps/backend/agents/README.md +++ b/apps/backend/agents/README.md @@ -26,7 +26,7 @@ auto-claude/agents/ ### `utils.py` (3.6 KB) - Git operations: `get_latest_commit()`, `get_commit_count()` - Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()` -- Workspace sync: `sync_plan_to_source()` +- Workspace sync: `sync_spec_to_source()` ### `memory.py` (13 KB) - Dual-layer memory system (Graphiti primary, file-based fallback) @@ -73,7 +73,7 @@ from agents import ( # Utilities get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) ``` diff --git a/apps/backend/agents/__init__.py b/apps/backend/agents/__init__.py index 37dae174..4eed4686 100644 --- a/apps/backend/agents/__init__.py +++ b/apps/backend/agents/__init__.py @@ -14,6 +14,10 @@ This module provides: Uses lazy imports to avoid circular dependencies. """ +# Explicit import required by CodeQL static analysis +# (CodeQL doesn't recognize __getattr__ dynamic exports) +from .utils import sync_spec_to_source + __all__ = [ # Main API "run_autonomous_agent", @@ -32,7 +36,7 @@ __all__ = [ "load_implementation_plan", "find_subtask_in_plan", "find_phase_for_subtask", - "sync_plan_to_source", + "sync_spec_to_source", # Constants "AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE", @@ -77,7 +81,7 @@ def __getattr__(name): "get_commit_count", "get_latest_commit", "load_implementation_plan", - "sync_plan_to_source", + "sync_spec_to_source", ): from .utils import ( find_phase_for_subtask, @@ -85,7 +89,7 @@ def __getattr__(name): get_commit_count, get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) return locals()[name] diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index 39d43b30..04cd019c 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -62,7 +62,7 @@ from .utils import ( get_commit_count, get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) logger = logging.getLogger(__name__) @@ -404,7 +404,7 @@ async def run_autonomous_agent( print_status("Linear notified of stuck subtask", "info") elif is_planning_phase and source_spec_dir: # After planning phase, sync the newly created implementation plan back to source - if sync_plan_to_source(spec_dir, source_spec_dir): + if sync_spec_to_source(spec_dir, source_spec_dir): print_status("Implementation plan synced to main project", "success") # Handle session status diff --git a/apps/backend/agents/session.py b/apps/backend/agents/session.py index 89a5d5d4..ff0c4c31 100644 --- a/apps/backend/agents/session.py +++ b/apps/backend/agents/session.py @@ -40,7 +40,7 @@ from .utils import ( get_commit_count, get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) logger = logging.getLogger(__name__) @@ -82,7 +82,7 @@ async def post_session_processing( print(muted("--- Post-Session Processing ---")) # Sync implementation plan back to source (for worktree mode) - if sync_plan_to_source(spec_dir, source_spec_dir): + if sync_spec_to_source(spec_dir, source_spec_dir): print_status("Implementation plan synced to main project", "success") # Check if implementation plan was updated diff --git a/apps/backend/agents/tools_pkg/tools/memory.py b/apps/backend/agents/tools_pkg/tools/memory.py index ac361ab7..7bc51ccb 100644 --- a/apps/backend/agents/tools_pkg/tools/memory.py +++ b/apps/backend/agents/tools_pkg/tools/memory.py @@ -4,9 +4,16 @@ Session Memory Tools Tools for recording and retrieving session memory, including discoveries, gotchas, and patterns. + +Dual-storage approach: +- File-based: Always available, works offline, spec-specific +- LadybugDB: When Graphiti is enabled, also saves to graph database for + cross-session retrieval and Memory UI display """ +import asyncio import json +import logging from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -19,6 +26,79 @@ except ImportError: SDK_TOOLS_AVAILABLE = False tool = None +logger = logging.getLogger(__name__) + + +def _save_to_graphiti_sync( + spec_dir: Path, + project_dir: Path, + save_type: str, + data: dict, +) -> bool: + """ + Save data to Graphiti/LadybugDB (synchronous wrapper for async operation). + + Args: + spec_dir: Spec directory for GraphitiMemory initialization + project_dir: Project root directory + save_type: Type of save - 'discovery', 'gotcha', or 'pattern' + data: Data to save + + Returns: + True if save succeeded, False otherwise + """ + try: + # Check if Graphiti is enabled + from graphiti_config import is_graphiti_enabled + + if not is_graphiti_enabled(): + return False + + from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory + + async def _async_save(): + memory = GraphitiMemory(spec_dir, project_dir) + try: + if save_type == "discovery": + # Save as codebase discovery + # Format: {file_path: description} + result = await memory.save_codebase_discoveries( + {data["file_path"]: data["description"]} + ) + elif save_type == "gotcha": + # Save as gotcha + gotcha_text = data["gotcha"] + if data.get("context"): + gotcha_text += f" (Context: {data['context']})" + result = await memory.save_gotcha(gotcha_text) + elif save_type == "pattern": + # Save as pattern + result = await memory.save_pattern(data["pattern"]) + else: + result = False + return result + finally: + await memory.close() + + # Run async operation in event loop + try: + asyncio.get_running_loop() + # If we're already in an async context, schedule the task + # Don't block - just fire and forget for the Graphiti save + # The file-based save is the primary, Graphiti is supplementary + asyncio.ensure_future(_async_save()) + return False # Can't confirm async success, file-based is source of truth + except RuntimeError: + # No running loop, create one + return asyncio.run(_async_save()) + + except ImportError as e: + logger.debug(f"Graphiti not available for memory tools: {e}") + return False + except Exception as e: + logger.warning(f"Failed to save to Graphiti: {e}") + return False + def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: """ @@ -45,7 +125,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: {"file_path": str, "description": str, "category": str}, ) async def record_discovery(args: dict[str, Any]) -> dict[str, Any]: - """Record a discovery to the codebase map.""" + """Record a discovery to the codebase map (file + Graphiti).""" file_path = args["file_path"] description = args["description"] category = args.get("category", "general") @@ -54,8 +134,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: memory_dir.mkdir(exist_ok=True) codebase_map_file = memory_dir / "codebase_map.json" + saved_to_graphiti = False try: + # PRIMARY: Save to file-based storage (always works) # Load existing map or create new if codebase_map_file.exists(): with open(codebase_map_file) as f: @@ -77,11 +159,23 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: with open(codebase_map_file, "w") as f: json.dump(codebase_map, f, indent=2) + # SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI) + saved_to_graphiti = _save_to_graphiti_sync( + spec_dir, + project_dir, + "discovery", + { + "file_path": file_path, + "description": f"[{category}] {description}", + }, + ) + + storage_note = " (also saved to memory graph)" if saved_to_graphiti else "" return { "content": [ { "type": "text", - "text": f"Recorded discovery for '{file_path}': {description}", + "text": f"Recorded discovery for '{file_path}': {description}{storage_note}", } ] } @@ -102,7 +196,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: {"gotcha": str, "context": str}, ) async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]: - """Record a gotcha to session memory.""" + """Record a gotcha to session memory (file + Graphiti).""" gotcha = args["gotcha"] context = args.get("context", "") @@ -110,8 +204,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: memory_dir.mkdir(exist_ok=True) gotchas_file = memory_dir / "gotchas.md" + saved_to_graphiti = False try: + # PRIMARY: Save to file-based storage (always works) timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") entry = f"\n## [{timestamp}]\n{gotcha}" @@ -126,7 +222,20 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: ) f.write(entry) - return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]} + # SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI) + saved_to_graphiti = _save_to_graphiti_sync( + spec_dir, + project_dir, + "gotcha", + {"gotcha": gotcha, "context": context}, + ) + + storage_note = " (also saved to memory graph)" if saved_to_graphiti else "" + return { + "content": [ + {"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"} + ] + } except Exception as e: return { diff --git a/apps/backend/agents/utils.py b/apps/backend/agents/utils.py index 8ce33c92..cc56cde2 100644 --- a/apps/backend/agents/utils.py +++ b/apps/backend/agents/utils.py @@ -23,9 +23,10 @@ def get_latest_commit(project_dir: Path) -> str | None: capture_output=True, text=True, check=True, + timeout=10, ) return result.stdout.strip() - except subprocess.CalledProcessError: + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): return None @@ -38,9 +39,10 @@ def get_commit_count(project_dir: Path) -> int: capture_output=True, text=True, check=True, + timeout=10, ) return int(result.stdout.strip()) - except (subprocess.CalledProcessError, ValueError): + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError): return 0 @@ -74,16 +76,32 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None: return None -def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: +def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: """ - Sync implementation_plan.json from worktree back to source spec directory. + Sync ALL spec files from worktree back to source spec directory. - When running in isolated mode (worktrees), the agent updates the implementation - plan inside the worktree. This function syncs those changes back to the main - project's spec directory so the frontend/UI can see the progress. + When running in isolated mode (worktrees), the agent creates and updates + many files inside the worktree's spec directory. This function syncs ALL + of them back to the main project's spec directory. + + IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the + local filesystem regardless of what branch the user is on. The worktree + may be on a different branch (e.g., auto-claude/093-task), but the sync + target is always the main project's .auto-claude/specs/ directory. + + Files synced (all files in spec directory): + - implementation_plan.json - Task status and subtask completion + - build-progress.txt - Session-by-session progress notes + - task_logs.json - Execution logs + - review_state.json - QA review state + - critique_report.json - Spec critique findings + - suggested_commit_message.txt - Commit suggestions + - REGRESSION_TEST_REPORT.md - Test regression report + - spec.md, context.json, etc. - Original spec files (for completeness) + - memory/ directory - Codebase map, patterns, gotchas, session insights Args: - spec_dir: Current spec directory (may be inside worktree) + spec_dir: Current spec directory (inside worktree) source_spec_dir: Original spec directory in main project (outside worktree) Returns: @@ -100,17 +118,68 @@ def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: if spec_dir_resolved == source_spec_dir_resolved: return False # Same directory, no sync needed - # Sync the implementation plan - plan_file = spec_dir / "implementation_plan.json" - if not plan_file.exists(): - return False + synced_any = False - source_plan_file = source_spec_dir / "implementation_plan.json" + # Ensure source directory exists + source_spec_dir.mkdir(parents=True, exist_ok=True) try: - shutil.copy2(plan_file, source_plan_file) - logger.debug(f"Synced implementation plan to source: {source_plan_file}") - return True + # Sync all files and directories from worktree spec to source spec + for item in spec_dir.iterdir(): + # Skip symlinks to prevent path traversal attacks + if item.is_symlink(): + logger.warning(f"Skipping symlink during sync: {item.name}") + continue + + source_item = source_spec_dir / item.name + + if item.is_file(): + # Copy file (preserves timestamps) + shutil.copy2(item, source_item) + logger.debug(f"Synced {item.name} to source") + synced_any = True + + elif item.is_dir(): + # Recursively sync directory + _sync_directory(item, source_item) + synced_any = True + except Exception as e: - logger.warning(f"Failed to sync implementation plan to source: {e}") - return False + logger.warning(f"Failed to sync spec directory to source: {e}") + + return synced_any + + +def _sync_directory(source_dir: Path, target_dir: Path) -> None: + """ + Recursively sync a directory from source to target. + + Args: + source_dir: Source directory (in worktree) + target_dir: Target directory (in main project) + """ + # Create target directory if needed + target_dir.mkdir(parents=True, exist_ok=True) + + for item in source_dir.iterdir(): + # Skip symlinks to prevent path traversal attacks + if item.is_symlink(): + logger.warning( + f"Skipping symlink during sync: {source_dir.name}/{item.name}" + ) + continue + + target_item = target_dir / item.name + + if item.is_file(): + shutil.copy2(item, target_item) + logger.debug(f"Synced {source_dir.name}/{item.name} to source") + elif item.is_dir(): + # Recurse into subdirectories + _sync_directory(item, target_item) + + +# Keep the old name as an alias for backward compatibility +def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: + """Alias for sync_spec_to_source for backward compatibility.""" + return sync_spec_to_source(spec_dir, source_spec_dir) diff --git a/apps/backend/cli/batch_commands.py b/apps/backend/cli/batch_commands.py index 73b9c6f9..959df5ee 100644 --- a/apps/backend/cli/batch_commands.py +++ b/apps/backend/cli/batch_commands.py @@ -6,6 +6,8 @@ Commands for creating and managing multiple tasks from batch files. """ import json +import shutil +import subprocess from pathlib import Path from ui import highlight, print_status @@ -212,5 +214,53 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/") print() print("Run with --no-dry-run to actually delete") + else: + # Actually delete specs and worktrees + deleted_count = 0 + for spec_name in completed: + spec_path = specs_dir / spec_name + wt_path = worktrees_dir / spec_name + + # Remove worktree first (if exists) + if wt_path.exists(): + try: + result = subprocess.run( + ["git", "worktree", "remove", "--force", str(wt_path)], + cwd=project_dir, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + print_status(f"Removed worktree: {spec_name}", "success") + else: + # Fallback: remove directory manually if git fails + shutil.rmtree(wt_path, ignore_errors=True) + print_status( + f"Removed worktree directory: {spec_name}", "success" + ) + except subprocess.TimeoutExpired: + # Timeout: fall back to manual removal + shutil.rmtree(wt_path, ignore_errors=True) + print_status( + f"Worktree removal timed out, removed directory: {spec_name}", + "warning", + ) + except Exception as e: + print_status( + f"Failed to remove worktree {spec_name}: {e}", "warning" + ) + + # Remove spec directory + if spec_path.exists(): + try: + shutil.rmtree(spec_path) + print_status(f"Removed spec: {spec_name}", "success") + deleted_count += 1 + except Exception as e: + print_status(f"Failed to remove spec {spec_name}: {e}", "error") + + print() + print_status(f"Cleaned up {deleted_count} spec(s)", "info") return True diff --git a/apps/backend/cli/build_commands.py b/apps/backend/cli/build_commands.py index 19dc17ca..ad5766ac 100644 --- a/apps/backend/cli/build_commands.py +++ b/apps/backend/cli/build_commands.py @@ -79,7 +79,7 @@ def handle_build_command( base_branch: Base branch for worktree creation (default: current branch) """ # Lazy imports to avoid loading heavy modules - from agent import run_autonomous_agent, sync_plan_to_source + from agent import run_autonomous_agent, sync_spec_to_source from debug import ( debug, debug_info, @@ -274,7 +274,7 @@ def handle_build_command( # Sync implementation plan to main project after QA # This ensures the main project has the latest status (human_review) - if sync_plan_to_source(spec_dir, source_spec_dir): + if sync_spec_to_source(spec_dir, source_spec_dir): debug_info( "run.py", "Implementation plan synced to main project after QA" ) diff --git a/apps/backend/cli/workspace_commands.py b/apps/backend/cli/workspace_commands.py index 5e3d68a5..08e239eb 100644 --- a/apps/backend/cli/workspace_commands.py +++ b/apps/backend/cli/workspace_commands.py @@ -67,6 +67,7 @@ def _detect_default_branch(project_dir: Path) -> str: cwd=project_dir, capture_output=True, text=True, + timeout=5, ) if result.returncode == 0: return env_branch @@ -78,6 +79,7 @@ def _detect_default_branch(project_dir: Path) -> str: cwd=project_dir, capture_output=True, text=True, + timeout=5, ) if result.returncode == 0: return branch @@ -90,18 +92,32 @@ def _get_changed_files_from_git( worktree_path: Path, base_branch: str = "main" ) -> list[str]: """ - Get list of changed files from git diff between base branch and HEAD. + Get list of files changed by the task (not files changed on base branch). + + Uses merge-base to accurately identify only the files modified in the worktree, + not files that changed on the base branch since the worktree was created. Args: worktree_path: Path to the worktree base_branch: Base branch to compare against (default: main) Returns: - List of changed file paths + List of changed file paths (task changes only) """ try: + # First, get the merge-base (the point where the worktree branched) + merge_base_result = subprocess.run( + ["git", "merge-base", base_branch, "HEAD"], + cwd=worktree_path, + capture_output=True, + text=True, + check=True, + ) + merge_base = merge_base_result.stdout.strip() + + # Use two-dot diff from merge-base to get only task's changes result = subprocess.run( - ["git", "diff", "--name-only", f"{base_branch}...HEAD"], + ["git", "diff", "--name-only", f"{merge_base}..HEAD"], cwd=worktree_path, capture_output=True, text=True, @@ -113,10 +129,10 @@ def _get_changed_files_from_git( # Log the failure before trying fallback debug_warning( "workspace_commands", - f"git diff (three-dot) failed: returncode={e.returncode}, " + f"git diff with merge-base failed: returncode={e.returncode}, " f"stderr={e.stderr.strip() if e.stderr else 'N/A'}", ) - # Fallback: try without the three-dot notation + # Fallback: try direct two-arg diff (less accurate but works) try: result = subprocess.run( ["git", "diff", "--name-only", base_branch, "HEAD"], @@ -131,7 +147,7 @@ def _get_changed_files_from_git( # Log the failure before returning empty list debug_warning( "workspace_commands", - f"git diff (two-arg) failed: returncode={e.returncode}, " + f"git diff (fallback) failed: returncode={e.returncode}, " f"stderr={e.stderr.strip() if e.stderr else 'N/A'}", ) return [] @@ -600,6 +616,13 @@ def handle_merge_preview_command( changed_files=all_changed_files[:10], # Log first 10 ) + # NOTE: We intentionally do NOT have a fast path here. + # Even if commits_behind == 0 (main hasn't moved), we still need to: + # 1. Call refresh_from_git() to update evolution data for this task + # 2. Call preview_merge() to detect potential conflicts with OTHER parallel tasks + # that may be tracked in the evolution data but haven't been merged yet. + # Skipping semantic analysis when commits_behind == 0 would miss these conflicts. + debug(MODULE, "Initializing MergeOrchestrator for preview...") # Initialize the orchestrator diff --git a/apps/backend/core/agent.py b/apps/backend/core/agent.py index 8b2cc8d5..6d9ffe37 100644 --- a/apps/backend/core/agent.py +++ b/apps/backend/core/agent.py @@ -39,7 +39,7 @@ from agents import ( run_followup_planner, save_session_memory, save_session_to_graphiti, - sync_plan_to_source, + sync_spec_to_source, ) # Ensure all exports are available at module level @@ -57,7 +57,7 @@ __all__ = [ "load_implementation_plan", "find_subtask_in_plan", "find_phase_for_subtask", - "sync_plan_to_source", + "sync_spec_to_source", "AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE", ] diff --git a/apps/backend/core/phase_event.py b/apps/backend/core/phase_event.py index a86321cf..acc03460 100644 --- a/apps/backend/core/phase_event.py +++ b/apps/backend/core/phase_event.py @@ -52,4 +52,8 @@ def emit_phase( print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True) except (OSError, UnicodeEncodeError) as e: if _DEBUG: - print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True) + try: + sys.stderr.write(f"[phase_event] emit failed: {e}\n") + sys.stderr.flush() + except (OSError, UnicodeEncodeError): + pass # Truly silent on complete I/O failure diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index b5b82572..171866f1 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -267,6 +267,15 @@ def setup_workspace( f"Environment files copied: {', '.join(copied_env_files)}", "success" ) + # Ensure .auto-claude/ is in the worktree's .gitignore + # This is critical because the worktree inherits .gitignore from the base branch, + # which may not have .auto-claude/ if that change wasn't committed/pushed. + # Without this, spec files would be committed to the worktree's branch. + from init import ensure_gitignore_entry + + if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"): + debug(MODULE, "Added .auto-claude/ to worktree's .gitignore") + # Copy spec files to worktree if provided localized_spec_dir = None if source_spec_dir and source_spec_dir.exists(): diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index df91f187..58f03714 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -124,17 +124,37 @@ class WorktreeManager: return result.stdout.strip() def _run_git( - self, args: list[str], cwd: Path | None = None + self, args: list[str], cwd: Path | None = None, timeout: int = 60 ) -> subprocess.CompletedProcess: - """Run a git command and return the result.""" - return subprocess.run( - ["git"] + args, - cwd=cwd or self.project_dir, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) + """Run a git command and return the result. + + Args: + args: Git command arguments (without 'git' prefix) + cwd: Working directory for the command + timeout: Command timeout in seconds (default: 60) + + Returns: + CompletedProcess with command results. On timeout, returns a + CompletedProcess with returncode=-1 and timeout error in stderr. + """ + try: + return subprocess.run( + ["git"] + args, + cwd=cwd or self.project_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + # Return a failed result on timeout instead of raising + return subprocess.CompletedProcess( + args=["git"] + args, + returncode=-1, + stdout="", + stderr=f"Command timed out after {timeout} seconds", + ) def _unstage_gitignored_files(self) -> None: """ @@ -327,9 +347,33 @@ class WorktreeManager: # Delete branch if it exists (from previous attempt) self._run_git(["branch", "-D", branch_name]) - # Create worktree with new branch from base + # Fetch latest from remote to ensure we have the most up-to-date code + # GitHub/remote is the source of truth, not the local branch + fetch_result = self._run_git(["fetch", "origin", self.base_branch]) + if fetch_result.returncode != 0: + print( + f"Warning: Could not fetch {self.base_branch} from origin: {fetch_result.stderr}" + ) + print("Falling back to local branch...") + + # Determine the start point for the worktree + # Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code + remote_ref = f"origin/{self.base_branch}" + start_point = self.base_branch # Default to local branch + + # Check if remote ref exists and use it as the source of truth + check_remote = self._run_git(["rev-parse", "--verify", remote_ref]) + if check_remote.returncode == 0: + start_point = remote_ref + print(f"Creating worktree from remote: {remote_ref}") + else: + print( + f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}" + ) + + # Create worktree with new branch from the start point (remote preferred) result = self._run_git( - ["worktree", "add", "-b", branch_name, str(worktree_path), self.base_branch] + ["worktree", "add", "-b", branch_name, str(worktree_path), start_point] ) if result.returncode != 0: diff --git a/apps/backend/merge/file_evolution/modification_tracker.py b/apps/backend/merge/file_evolution/modification_tracker.py index b4cc281a..54cdc8e0 100644 --- a/apps/backend/merge/file_evolution/modification_tracker.py +++ b/apps/backend/merge/file_evolution/modification_tracker.py @@ -87,8 +87,8 @@ class ModificationTracker: # Get or create evolution if rel_path not in evolutions: - logger.warning(f"File {rel_path} not being tracked") - # Note: We could auto-create here, but for now return None + # Debug level: this is expected for files not in baseline (e.g., from main's changes) + logger.debug(f"File {rel_path} not in evolution tracking - skipping") return None evolution = evolutions.get(rel_path) @@ -157,9 +157,21 @@ class ModificationTracker: ) try: - # Get list of files changed in the worktree vs target branch + # Get the merge-base to accurately identify task-only changes + # Using two-dot diff (merge-base..HEAD) returns only files changed by the task, + # not files changed on the target branch since divergence + merge_base_result = subprocess.run( + ["git", "merge-base", target_branch, "HEAD"], + cwd=worktree_path, + capture_output=True, + text=True, + check=True, + ) + merge_base = merge_base_result.stdout.strip() + + # Get list of files changed in the worktree since the merge-base result = subprocess.run( - ["git", "diff", "--name-only", f"{target_branch}...HEAD"], + ["git", "diff", "--name-only", f"{merge_base}..HEAD"], cwd=worktree_path, capture_output=True, text=True, @@ -176,19 +188,19 @@ class ModificationTracker: ) for file_path in changed_files: - # Get the diff for this file + # Get the diff for this file (using merge-base for accurate task-only diff) diff_result = subprocess.run( - ["git", "diff", f"{target_branch}...HEAD", "--", file_path], + ["git", "diff", f"{merge_base}..HEAD", "--", file_path], cwd=worktree_path, capture_output=True, text=True, check=True, ) - # Get content before (from target branch) and after (current) + # Get content before (from merge-base - the point where task branched) try: show_result = subprocess.run( - ["git", "show", f"{target_branch}:{file_path}"], + ["git", "show", f"{merge_base}:{file_path}"], cwd=worktree_path, capture_output=True, text=True, diff --git a/apps/backend/merge/file_merger.py b/apps/backend/merge/file_merger.py index 53cebc4d..4a149bcd 100644 --- a/apps/backend/merge/file_merger.py +++ b/apps/backend/merge/file_merger.py @@ -19,6 +19,35 @@ from pathlib import Path from .types import ChangeType, SemanticChange, TaskSnapshot +def detect_line_ending(content: str) -> str: + """ + Detect line ending style in content using priority-based detection. + + Uses a priority order (CRLF > CR > LF) to detect the line ending style. + CRLF is checked first because it contains LF, so presence of any CRLF + indicates Windows-style endings. This approach is fast and works well + for files that consistently use one style. + + Note: This returns the first detected style by priority, not the most + frequent style. For files with mixed line endings, consider normalizing + to a single style before processing. + + Args: + content: File content to analyze + + Returns: + The detected line ending string: "\\r\\n", "\\r", or "\\n" + """ + # Check for CRLF first (Windows) - must check before LF since CRLF contains LF + if "\r\n" in content: + return "\r\n" + # Check for CR (classic Mac, rare but possible) + if "\r" in content: + return "\r" + # Default to LF (Unix/modern Mac) + return "\n" + + def apply_single_task_changes( baseline: str, snapshot: TaskSnapshot, @@ -37,6 +66,9 @@ def apply_single_task_changes( """ content = baseline + # Detect line ending style once at the start to use consistently + line_ending = detect_line_ending(content) + for change in snapshot.semantic_changes: if change.content_before and change.content_after: # Modification - replace @@ -45,14 +77,13 @@ def apply_single_task_changes( # Addition - need to determine where to add if change.change_type == ChangeType.ADD_IMPORT: # Add import at top - # Use splitlines() to handle all line ending styles (LF, CRLF, CR) lines = content.splitlines() import_end = find_import_end(lines, file_path) lines.insert(import_end, change.content_after) - content = "\n".join(lines) + content = line_ending.join(lines) elif change.change_type == ChangeType.ADD_FUNCTION: # Add function at end (before exports) - content += f"\n\n{change.content_after}" + content += f"{line_ending}{line_ending}{change.content_after}" return content @@ -75,6 +106,9 @@ def combine_non_conflicting_changes( """ content = baseline + # Detect line ending style once at the start to use consistently + line_ending = detect_line_ending(content) + # Group changes by type for proper ordering imports: list[SemanticChange] = [] functions: list[SemanticChange] = [] @@ -97,14 +131,13 @@ def combine_non_conflicting_changes( # Add imports if imports: - # Use splitlines() to handle all line ending styles (LF, CRLF, CR) lines = content.splitlines() import_end = find_import_end(lines, file_path) for imp in imports: if imp.content_after and imp.content_after not in content: lines.insert(import_end, imp.content_after) import_end += 1 - content = "\n".join(lines) + content = line_ending.join(lines) # Apply modifications for mod in modifications: @@ -114,12 +147,12 @@ def combine_non_conflicting_changes( # Add functions for func in functions: if func.content_after: - content += f"\n\n{func.content_after}" + content += f"{line_ending}{line_ending}{func.content_after}" # Apply other changes for change in other: if change.content_after and not change.content_before: - content += f"\n{change.content_after}" + content += f"{line_ending}{change.content_after}" elif change.content_before and change.content_after: content = content.replace(change.content_before, change.content_after) diff --git a/apps/backend/prompts/coder.md b/apps/backend/prompts/coder.md index c9cde7f3..36df3e35 100644 --- a/apps/backend/prompts/coder.md +++ b/apps/backend/prompts/coder.md @@ -634,7 +634,7 @@ The system **automatically scans for secrets** before every commit. If secrets a api_key = os.environ.get("API_KEY") ``` 3. **Update .env.example** - Add placeholder for the new variable -4. **Re-stage and retry** - `git add . && git commit ...` +4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...` **If it's a false positive:** - Add the file pattern to `.secretsignore` in the project root @@ -643,7 +643,8 @@ The system **automatically scans for secrets** before every commit. If secrets a ### Create the Commit ```bash -git add . +# Add all files EXCEPT .auto-claude directory (spec files should never be committed) +git add . ':!.auto-claude' git commit -m "auto-claude: Complete [subtask-id] - [subtask description] - Files modified: [list] @@ -651,6 +652,9 @@ git commit -m "auto-claude: Complete [subtask-id] - [subtask description] - Phase progress: [X]/[Y] subtasks complete" ``` +**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed. +These are internal tracking files that must stay local. + ### DO NOT Push to Remote **IMPORTANT**: Do NOT run `git push`. All work stays local until the user reviews and approves. @@ -956,6 +960,17 @@ Prepare → Test (small batch) → Execute (full) → Cleanup - Clean, working state - **Secret scan must pass before commit** +### Git Configuration - NEVER MODIFY +**CRITICAL**: You MUST NOT modify git user configuration. Never run: +- `git config user.name` +- `git config user.email` +- `git config --local user.*` +- `git config --global user.*` + +The repository inherits the user's configured git identity. Creating "Test User" or +any other fake identity breaks attribution and causes serious issues. If you need +to commit changes, use the existing git identity - do NOT set a new one. + ### The Golden Rule **FIX BUGS NOW.** The next session has no memory. diff --git a/apps/backend/prompts/github/pr_followup_orchestrator.md b/apps/backend/prompts/github/pr_followup_orchestrator.md index 08a191f4..4e714df4 100644 --- a/apps/backend/prompts/github/pr_followup_orchestrator.md +++ b/apps/backend/prompts/github/pr_followup_orchestrator.md @@ -131,7 +131,21 @@ After all agents complete: ## Verdict Guidelines +### CRITICAL: CI Status ALWAYS Factors Into Verdict + +**CI status is provided in the context and MUST be considered:** + +- ❌ **Failing CI = BLOCKED** - If ANY CI checks are failing, verdict MUST be BLOCKED regardless of code quality +- ⏳ **Pending CI = NEEDS_REVISION** - If CI is still running, verdict cannot be READY_TO_MERGE +- ⏸️ **Awaiting approval = BLOCKED** - Fork PR workflows awaiting maintainer approval block merge +- ✅ **All passing = Continue with code analysis** - Only then do code findings determine verdict + +**Always mention CI status in your verdict_reasoning.** For example: +- "BLOCKED: 2 CI checks failing (CodeQL, test-frontend). Fix CI before merge." +- "READY_TO_MERGE: All CI checks passing and all findings resolved." + ### READY_TO_MERGE +- **All CI checks passing** (no failing, no pending) - All previous findings verified as resolved OR dismissed as false positives - No CONFIRMED_VALID critical/high issues remaining - No new critical/high issues @@ -139,11 +153,13 @@ After all agents complete: - Contributor questions addressed ### MERGE_WITH_CHANGES +- **All CI checks passing** - Previous findings resolved - Only LOW severity new issues (suggestions) - Optional polish items can be addressed post-merge ### NEEDS_REVISION (Strict Quality Gates) +- **CI checks pending** OR - HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive) - New HIGH or MEDIUM severity issues introduced - Important contributor concerns unaddressed @@ -151,6 +167,8 @@ After all agents complete: - **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block) ### BLOCKED +- **Any CI checks failing** OR +- **Workflows awaiting maintainer approval** (fork PRs) OR - CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive) - New CRITICAL issues introduced - Fundamental problems with the fix approach @@ -234,6 +252,7 @@ false positives persist forever and developers lose trust in the review system. ## Context You Will Receive +- **CI Status (CRITICAL)** - Passing/failing/pending checks and specific failed check names - Previous review summary and findings - New commits since last review (SHAs, messages) - Diff of changes since last review diff --git a/apps/backend/prompts/qa_fixer.md b/apps/backend/prompts/qa_fixer.md index 85077569..b4959a01 100644 --- a/apps/backend/prompts/qa_fixer.md +++ b/apps/backend/prompts/qa_fixer.md @@ -167,7 +167,8 @@ If any issue is not fixed, go back to Phase 3. ## PHASE 6: COMMIT FIXES ```bash -git add . +# Add all files EXCEPT .auto-claude directory (spec files should never be committed) +git add . ':!.auto-claude' git commit -m "fix: Address QA issues (qa-requested) Fixes: @@ -182,6 +183,8 @@ Verified: QA Fix Session: [N]" ``` +**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed. + **NOTE**: Do NOT push to remote. All work stays local until user reviews and approves. --- @@ -304,6 +307,13 @@ npx prisma migrate dev --name [name] - How you verified - Commit messages +### Git Configuration - NEVER MODIFY +**CRITICAL**: You MUST NOT modify git user configuration. Never run: +- `git config user.name` +- `git config user.email` + +The repository inherits the user's configured git identity. Do NOT set test users. + --- ## QA LOOP BEHAVIOR diff --git a/apps/backend/prompts/qa_reviewer.md b/apps/backend/prompts/qa_reviewer.md index d986a41b..ff52320a 100644 --- a/apps/backend/prompts/qa_reviewer.md +++ b/apps/backend/prompts/qa_reviewer.md @@ -35,8 +35,8 @@ cat project_index.json # 4. Check build progress cat build-progress.txt -# 5. See what files were changed -git diff main --name-only +# 5. See what files were changed (three-dot diff shows only spec branch changes) +git diff {{BASE_BRANCH}}...HEAD --name-status # 6. Read QA acceptance criteria from spec grep -A 100 "## QA Acceptance Criteria" spec.md @@ -514,7 +514,7 @@ All acceptance criteria verified: The implementation is production-ready. Sign-off recorded in implementation_plan.json. -Ready for merge to main. +Ready for merge to {{BASE_BRANCH}}. ``` ### If Rejected: diff --git a/apps/backend/prompts_pkg/prompts.py b/apps/backend/prompts_pkg/prompts.py index acb29d73..83a87269 100644 --- a/apps/backend/prompts_pkg/prompts.py +++ b/apps/backend/prompts_pkg/prompts.py @@ -7,7 +7,9 @@ Supports dynamic prompt assembly based on project type for context optimization. """ import json +import os import re +import subprocess from pathlib import Path from .project_context import ( @@ -16,6 +18,133 @@ from .project_context import ( load_project_index, ) + +def _validate_branch_name(branch: str | None) -> str | None: + """ + Validate a git branch name for safety and correctness. + + Args: + branch: The branch name to validate + + Returns: + The validated branch name, or None if invalid + """ + if not branch or not isinstance(branch, str): + return None + + # Trim whitespace + branch = branch.strip() + + # Reject empty or whitespace-only strings + if not branch: + return None + + # Enforce maximum length (git refs can be long, but 255 is reasonable) + if len(branch) > 255: + return None + + # Require at least one alphanumeric character + if not any(c.isalnum() for c in branch): + return None + + # Only allow common git-ref characters: letters, numbers, ., _, -, / + # This prevents prompt injection and other security issues + if not re.match(r"^[A-Za-z0-9._/-]+$", branch): + return None + + # Reject suspicious patterns that could be prompt injection attempts + # (newlines, control characters are already blocked by the regex above) + + return branch + + +def _get_base_branch_from_metadata(spec_dir: Path) -> str | None: + """ + Read baseBranch from task_metadata.json if it exists. + + Args: + spec_dir: Directory containing the spec files + + Returns: + The baseBranch from metadata, or None if not found or invalid + """ + metadata_path = spec_dir / "task_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path, encoding="utf-8") as f: + metadata = json.load(f) + base_branch = metadata.get("baseBranch") + # Validate the branch name before returning + return _validate_branch_name(base_branch) + except (json.JSONDecodeError, OSError): + pass + return None + + +def _detect_base_branch(spec_dir: Path, project_dir: Path) -> str: + """ + Detect the base branch for a project/task. + + Priority order: + 1. baseBranch from task_metadata.json (task-level override) + 2. DEFAULT_BRANCH environment variable + 3. Auto-detect main/master/develop (if they exist in git) + 4. Fall back to "main" + + Args: + spec_dir: Directory containing the spec files + project_dir: Project root directory + + Returns: + The detected base branch name + """ + # 1. Check task_metadata.json for task-specific baseBranch + metadata_branch = _get_base_branch_from_metadata(spec_dir) + if metadata_branch: + return metadata_branch + + # 2. Check for DEFAULT_BRANCH env var + env_branch = _validate_branch_name(os.getenv("DEFAULT_BRANCH")) + if env_branch: + # Verify the branch exists (with timeout to prevent hanging) + try: + result = subprocess.run( + ["git", "rev-parse", "--verify", env_branch], + cwd=project_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + ) + if result.returncode == 0: + return env_branch + except subprocess.TimeoutExpired: + # Treat timeout as branch verification failure + pass + + # 3. Auto-detect main/master/develop + for branch in ["main", "master", "develop"]: + try: + result = subprocess.run( + ["git", "rev-parse", "--verify", branch], + cwd=project_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + ) + if result.returncode == 0: + return branch + except subprocess.TimeoutExpired: + # Treat timeout as branch verification failure, try next branch + continue + + # 4. Fall back to "main" + return "main" + + # Directory containing prompt files # prompts/ is a sibling directory of prompts_pkg/, so go up one level first PROMPTS_DIR = Path(__file__).parent.parent / "prompts" @@ -304,6 +433,7 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str: 1. Loads the base QA reviewer prompt 2. Detects project capabilities from project_index.json 3. Injects only relevant MCP tool documentation (Electron, Puppeteer, DB, API) + 4. Detects and injects the correct base branch for git comparisons This saves context window by excluding irrelevant tool docs. For example, a CLI Python project won't get Electron validation docs. @@ -315,9 +445,15 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str: Returns: The QA reviewer prompt with project-specific tools injected """ + # Detect the base branch for this task (from task_metadata.json or auto-detect) + base_branch = _detect_base_branch(spec_dir, project_dir) + # Load base QA reviewer prompt base_prompt = _load_prompt_file("qa_reviewer.md") + # Replace {{BASE_BRANCH}} placeholder with the actual base branch + base_prompt = base_prompt.replace("{{BASE_BRANCH}}", base_branch) + # Load project index and detect capabilities project_index = load_project_index(project_dir) capabilities = detect_project_capabilities(project_index) @@ -347,6 +483,17 @@ Your spec and progress files are located at: The project root is: `{project_dir}` +## GIT BRANCH CONFIGURATION + +**Base branch for comparison:** `{base_branch}` + +When checking for unrelated changes, use three-dot diff syntax: +```bash +git diff {base_branch}...HEAD --name-status +``` + +This shows only changes made in the spec branch since it diverged from `{base_branch}`. + --- ## PROJECT CAPABILITIES DETECTED diff --git a/apps/backend/query_memory.py b/apps/backend/query_memory.py index c16f82d9..e729e892 100644 --- a/apps/backend/query_memory.py +++ b/apps/backend/query_memory.py @@ -185,24 +185,31 @@ def cmd_get_memories(args): """ result = conn.execute(query, parameters={"limit": limit}) - df = result.get_as_df() + # Process results without pandas (iterate through result set directly) memories = [] - for _, row in df.iterrows(): + while result.has_next(): + row = result.get_next() + # Row order: uuid, name, created_at, content, description, group_id + uuid_val = serialize_value(row[0]) if len(row) > 0 else None + name_val = serialize_value(row[1]) if len(row) > 1 else "" + created_at_val = serialize_value(row[2]) if len(row) > 2 else None + content_val = serialize_value(row[3]) if len(row) > 3 else "" + description_val = serialize_value(row[4]) if len(row) > 4 else "" + group_id_val = serialize_value(row[5]) if len(row) > 5 else "" + memory = { - "id": row.get("uuid") or row.get("name", "unknown"), - "name": row.get("name", ""), - "type": infer_episode_type(row.get("name", ""), row.get("content", "")), - "timestamp": row.get("created_at") or datetime.now().isoformat(), - "content": row.get("content") - or row.get("description") - or row.get("name", ""), - "description": row.get("description", ""), - "group_id": row.get("group_id", ""), + "id": uuid_val or name_val or "unknown", + "name": name_val or "", + "type": infer_episode_type(name_val or "", content_val or ""), + "timestamp": created_at_val or datetime.now().isoformat(), + "content": content_val or description_val or name_val or "", + "description": description_val or "", + "group_id": group_id_val or "", } # Extract session number if present - session_num = extract_session_number(row.get("name", "")) + session_num = extract_session_number(name_val or "") if session_num: memory["session_number"] = session_num @@ -251,24 +258,31 @@ def cmd_search(args): result = conn.execute( query, parameters={"search_query": search_query, "limit": limit} ) - df = result.get_as_df() + # Process results without pandas memories = [] - for _, row in df.iterrows(): + while result.has_next(): + row = result.get_next() + # Row order: uuid, name, created_at, content, description, group_id + uuid_val = serialize_value(row[0]) if len(row) > 0 else None + name_val = serialize_value(row[1]) if len(row) > 1 else "" + created_at_val = serialize_value(row[2]) if len(row) > 2 else None + content_val = serialize_value(row[3]) if len(row) > 3 else "" + description_val = serialize_value(row[4]) if len(row) > 4 else "" + group_id_val = serialize_value(row[5]) if len(row) > 5 else "" + memory = { - "id": row.get("uuid") or row.get("name", "unknown"), - "name": row.get("name", ""), - "type": infer_episode_type(row.get("name", ""), row.get("content", "")), - "timestamp": row.get("created_at") or datetime.now().isoformat(), - "content": row.get("content") - or row.get("description") - or row.get("name", ""), - "description": row.get("description", ""), - "group_id": row.get("group_id", ""), + "id": uuid_val or name_val or "unknown", + "name": name_val or "", + "type": infer_episode_type(name_val or "", content_val or ""), + "timestamp": created_at_val or datetime.now().isoformat(), + "content": content_val or description_val or name_val or "", + "description": description_val or "", + "group_id": group_id_val or "", "score": 1.0, # Keyword match score } - session_num = extract_session_number(row.get("name", "")) + session_num = extract_session_number(name_val or "") if session_num: memory["session_number"] = session_num @@ -461,19 +475,26 @@ def cmd_get_entities(args): """ result = conn.execute(query, parameters={"limit": limit}) - df = result.get_as_df() + # Process results without pandas entities = [] - for _, row in df.iterrows(): - if not row.get("summary"): + while result.has_next(): + row = result.get_next() + # Row order: uuid, name, summary, created_at + uuid_val = serialize_value(row[0]) if len(row) > 0 else None + name_val = serialize_value(row[1]) if len(row) > 1 else "" + summary_val = serialize_value(row[2]) if len(row) > 2 else "" + created_at_val = serialize_value(row[3]) if len(row) > 3 else None + + if not summary_val: continue entity = { - "id": row.get("uuid") or row.get("name", "unknown"), - "name": row.get("name", ""), - "type": infer_entity_type(row.get("name", "")), - "timestamp": row.get("created_at") or datetime.now().isoformat(), - "content": row.get("summary", ""), + "id": uuid_val or name_val or "unknown", + "name": name_val or "", + "type": infer_entity_type(name_val or ""), + "timestamp": created_at_val or datetime.now().isoformat(), + "content": summary_val or "", } entities.append(entity) @@ -488,6 +509,118 @@ def cmd_get_entities(args): output_error(f"Query failed: {e}") +def cmd_add_episode(args): + """ + Add a new episode to the memory database. + + This is called from the Electron main process to save PR review insights, + patterns, gotchas, and other memories directly to the LadybugDB database. + + Args: + args.db_path: Path to database directory + args.database: Database name + args.name: Episode name/title + args.content: Episode content (JSON string) + args.episode_type: Type of episode (session_insight, pattern, gotcha, task_outcome, pr_review) + args.group_id: Optional group ID for namespacing + """ + if not apply_monkeypatch(): + output_error("Neither kuzu nor LadybugDB is installed") + return + + try: + import uuid as uuid_module + + try: + import kuzu + except ImportError: + import real_ladybug as kuzu + + # Parse content from JSON if provided + content = args.content + if content: + try: + # Try to parse as JSON to validate + parsed = json.loads(content) + # Re-serialize to ensure consistent formatting + content = json.dumps(parsed) + except json.JSONDecodeError: + # If not valid JSON, use as-is + pass + + # Generate unique ID + episode_uuid = str(uuid_module.uuid4()) + created_at = datetime.now().isoformat() + + # Get database path - create directory if needed + full_path = Path(args.db_path) / args.database + if not full_path.exists(): + # For new databases, create the parent directory + Path(args.db_path).mkdir(parents=True, exist_ok=True) + + # Open database (creates it if it doesn't exist) + db = kuzu.Database(str(full_path)) + conn = kuzu.Connection(db) + + # Always try to create the Episodic table if it doesn't exist + # This handles both new databases and existing databases without the table + try: + conn.execute(""" + CREATE NODE TABLE IF NOT EXISTS Episodic ( + uuid STRING PRIMARY KEY, + name STRING, + content STRING, + source_description STRING, + group_id STRING, + created_at STRING + ) + """) + except Exception as schema_err: + # Table might already exist with different schema - that's ok + # The insert will fail if schema is incompatible + sys.stderr.write(f"Schema creation note: {schema_err}\n") + + # Insert the episode + try: + insert_query = """ + CREATE (e:Episodic { + uuid: $uuid, + name: $name, + content: $content, + source_description: $description, + group_id: $group_id, + created_at: $created_at + }) + """ + conn.execute( + insert_query, + parameters={ + "uuid": episode_uuid, + "name": args.name, + "content": content, + "description": f"[{args.episode_type}] {args.name}", + "group_id": args.group_id or "", + "created_at": created_at, + }, + ) + + output_json( + True, + data={ + "id": episode_uuid, + "name": args.name, + "type": args.episode_type, + "timestamp": created_at, + }, + ) + + except Exception as e: + output_error(f"Failed to insert episode: {e}") + + except Exception as e: + output_error(f"Failed to add episode: {e}") + + def infer_episode_type(name: str, content: str = "") -> str: """Infer the episode type from its name and content.""" name_lower = (name or "").lower() @@ -580,6 +713,27 @@ def main(): "--limit", type=int, default=20, help="Maximum results" ) + # add-episode command (for saving memories from Electron app) + add_parser = subparsers.add_parser( + "add-episode", + help="Add an episode to the memory database (called from Electron)", + ) + add_parser.add_argument("db_path", help="Path to database directory") + add_parser.add_argument("database", help="Database name") + add_parser.add_argument("--name", required=True, help="Episode name/title") + add_parser.add_argument( + "--content", required=True, help="Episode content (JSON string)" + ) + add_parser.add_argument( + "--type", + dest="episode_type", + default="session_insight", + help="Episode type (session_insight, pattern, gotcha, task_outcome, pr_review)", + ) + add_parser.add_argument( + "--group-id", dest="group_id", help="Optional group ID for namespacing" + ) + args = parser.parse_args() if not args.command: @@ -594,6 +748,7 @@ def main(): "search": cmd_search, "semantic-search": cmd_semantic_search, "get-entities": cmd_get_entities, + "add-episode": cmd_add_episode, } handler = commands.get(args.command) diff --git a/apps/backend/runners/github/gh_client.py b/apps/backend/runners/github/gh_client.py index 954f7587..4ade5f91 100644 --- a/apps/backend/runners/github/gh_client.py +++ b/apps/backend/runners/github/gh_client.py @@ -875,6 +875,128 @@ class GHClient: "error": str(e), } + async def get_workflows_awaiting_approval(self, pr_number: int) -> dict[str, Any]: + """ + Get workflow runs awaiting approval for a PR from a fork. + + Workflows from forked repositories require manual approval before running. + These are NOT included in `gh pr checks` and must be queried separately. + + Args: + pr_number: PR number + + Returns: + Dict with: + - awaiting_approval: Number of workflows waiting for approval + - workflow_runs: List of workflow runs with id, name, html_url + - can_approve: Whether this token can approve workflows + """ + try: + # First, get the PR's head SHA to filter workflow runs + pr_args = ["pr", "view", str(pr_number), "--json", "headRefOid"] + pr_args = self._add_repo_flag(pr_args) + pr_result = await self.run(pr_args, timeout=30.0) + pr_data = json.loads(pr_result.stdout) if pr_result.stdout.strip() else {} + head_sha = pr_data.get("headRefOid", "") + + if not head_sha: + return { + "awaiting_approval": 0, + "workflow_runs": [], + "can_approve": False, + } + + # Query workflow runs with action_required status + # Note: We need to use the API endpoint as gh CLI doesn't have direct support + endpoint = ( + "repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100" + ) + args = ["api", "--method", "GET", endpoint] + + result = await self.run(args, timeout=30.0) + data = json.loads(result.stdout) if result.stdout.strip() else {} + all_runs = data.get("workflow_runs", []) + + # Filter to only runs for this PR's head SHA + pr_runs = [ + { + "id": run.get("id"), + "name": run.get("name"), + "html_url": run.get("html_url"), + "workflow_name": run.get("workflow", {}).get("name", "Unknown"), + } + for run in all_runs + if run.get("head_sha") == head_sha + ] + + return { + "awaiting_approval": len(pr_runs), + "workflow_runs": pr_runs, + "can_approve": True, # Assume token has permission, will fail if not + } + except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e: + logger.warning( + f"Failed to get workflows awaiting approval for #{pr_number}: {e}" + ) + return { + "awaiting_approval": 0, + "workflow_runs": [], + "can_approve": False, + "error": str(e), + } + + async def approve_workflow_run(self, run_id: int) -> bool: + """ + Approve a workflow run that's waiting for approval (from a fork). + + Args: + run_id: The workflow run ID to approve + + Returns: + True if approval succeeded, False otherwise + """ + try: + endpoint = f"repos/{{owner}}/{{repo}}/actions/runs/{run_id}/approve" + args = ["api", "--method", "POST", endpoint] + + await self.run(args, timeout=30.0) + logger.info(f"Approved workflow run {run_id}") + return True + except (GHCommandError, GHTimeoutError) as e: + logger.warning(f"Failed to approve workflow run {run_id}: {e}") + return False + + async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]: + """ + Get comprehensive CI status including workflows awaiting approval. + + This combines: + - Standard check runs from `gh pr checks` + - Workflows awaiting approval (for fork PRs) + + Args: + pr_number: PR number + + Returns: + Dict with all check information including awaiting_approval count + """ + # Get standard checks + checks = await self.get_pr_checks(pr_number) + + # Get workflows awaiting approval + awaiting = await self.get_workflows_awaiting_approval(pr_number) + + # Merge the results + checks["awaiting_approval"] = awaiting.get("awaiting_approval", 0) + checks["awaiting_workflow_runs"] = awaiting.get("workflow_runs", []) + + # Update pending count to include awaiting approval + checks["pending"] = checks.get("pending", 0) + awaiting.get( + "awaiting_approval", 0 + ) + + return checks + async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]: """ Get files changed by a PR using the PR files endpoint. @@ -1007,7 +1129,9 @@ class GHClient: Returns: Tuple of: - List of file objects that are part of the PR (filtered if blob comparison used) - - List of commit objects that are part of the PR and after base_sha + - List of commit objects that are part of the PR and after base_sha. + NOTE: Returns empty list if rebase/force-push detected, since commit SHAs + are rewritten and we cannot determine which commits are truly "new". """ # Get PR's canonical files (these are the actual PR changes) pr_files = await self.get_pr_files(pr_number) @@ -1072,12 +1196,14 @@ class GHClient: f"{unchanged_count} unchanged (skipped)" ) - # Return filtered files but all commits (can't filter commits after rebase) - return changed_files, pr_commits + # Return filtered files but empty commits list (can't determine "new" commits after rebase) + # After a rebase, all commit SHAs are rewritten so we can't identify which are truly new. + # The file changes via blob comparison are the reliable source of what changed. + return changed_files, [] - # No blob data available - return all files and commits + # No blob data available - return all files but empty commits (can't determine new commits) logger.warning( - "No reviewed_file_blobs available for blob comparison. " - "Returning all PR files." + "No reviewed_file_blobs available for blob comparison after rebase. " + "Returning all PR files with empty commits list." ) - return pr_files, pr_commits + return pr_files, [] diff --git a/apps/backend/runners/github/models.py b/apps/backend/runners/github/models.py index d3af57a8..0d95eb2a 100644 --- a/apps/backend/runners/github/models.py +++ b/apps/backend/runners/github/models.py @@ -570,6 +570,10 @@ class FollowupReviewContext: "" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE ) + # CI status - passed to AI orchestrator so it can factor into verdict + # Dict with: passing, failing, pending, failed_checks, awaiting_approval + ci_status: dict = field(default_factory=dict) + # Error flag - if set, context gathering failed and data may be incomplete error: str | None = None diff --git a/apps/backend/runners/github/orchestrator.py b/apps/backend/runners/github/orchestrator.py index e18c7b36..7b9fa5f3 100644 --- a/apps/backend/runners/github/orchestrator.py +++ b/apps/backend/runners/github/orchestrator.py @@ -389,13 +389,29 @@ class GitHubOrchestrator: pr_number=pr_number, ) - # Check CI status - ci_status = await self.gh_client.get_pr_checks(pr_number) + # Check CI status (comprehensive - includes workflows awaiting approval) + ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number) + + # Log CI status with awaiting approval info + awaiting = ci_status.get("awaiting_approval", 0) + pending_without_awaiting = ci_status.get("pending", 0) - awaiting + ci_log_parts = [ + f"{ci_status.get('passing', 0)} passing", + f"{ci_status.get('failing', 0)} failing", + ] + if pending_without_awaiting > 0: + ci_log_parts.append(f"{pending_without_awaiting} pending") + if awaiting > 0: + ci_log_parts.append(f"{awaiting} awaiting approval") print( - f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, " - f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending", + f"[orchestrator] CI status: {', '.join(ci_log_parts)}", flush=True, ) + if awaiting > 0: + print( + f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run", + flush=True, + ) # Generate verdict (now includes CI status) verdict, verdict_reasoning, blockers = self._generate_verdict( @@ -500,6 +516,9 @@ class GitHubOrchestrator: # Save result await result.save(self.github_dir) + # Note: PR review memory is now saved by the Electron app after the review completes + # This ensures memory is saved to the embedded LadybugDB managed by the app + # Mark as reviewed (head_sha already fetched above) if head_sha: self.bot_detector.mark_reviewed(pr_number, head_sha) @@ -615,19 +634,29 @@ class GitHubOrchestrator: await result.save(self.github_dir) return result - # Check if there are new commits - if not followup_context.commits_since_review: + # Check if there are changes to review (commits OR files via blob comparison) + # After a rebase/force-push, commits_since_review will be empty (commit + # SHAs are rewritten), but files_changed_since_review will contain files + # that actually changed content based on blob SHA comparison. + has_commits = bool(followup_context.commits_since_review) + has_file_changes = bool(followup_context.files_changed_since_review) + + if not has_commits and not has_file_changes: + base_sha = previous_review.reviewed_commit_sha[:8] print( - f"[Followup] No new commits since last review at {previous_review.reviewed_commit_sha[:8]}", + f"[Followup] No changes since last review at {base_sha}", flush=True, ) # Return a result indicating no changes + no_change_summary = ( + "No new commits since last review. Previous findings still apply." + ) result = PRReviewResult( pr_number=pr_number, repo=self.config.repo, success=True, findings=previous_review.findings, - summary="No new commits since last review. Previous findings still apply.", + summary=no_change_summary, overall_status=previous_review.overall_status, verdict=previous_review.verdict, verdict_reasoning="No changes since last review.", @@ -639,13 +668,26 @@ class GitHubOrchestrator: await result.save(self.github_dir) return result + # Build progress message based on what changed + if has_commits: + num_commits = len(followup_context.commits_since_review) + change_desc = f"{num_commits} new commits" + else: + # Rebase detected - files changed but no trackable commits + num_files = len(followup_context.files_changed_since_review) + change_desc = f"{num_files} files (rebase detected)" + self._report_progress( "analyzing", 30, - f"Analyzing {len(followup_context.commits_since_review)} new commits...", + f"Analyzing {change_desc}...", pr_number=pr_number, ) + # Fetch CI status BEFORE calling reviewer so AI can factor it into verdict + ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number) + followup_context.ci_status = ci_status + # Use parallel orchestrator for follow-up if enabled if self.config.use_parallel_orchestrator: print( @@ -690,9 +732,9 @@ class GitHubOrchestrator: ) result = await reviewer.review_followup(followup_context) - # Check CI status and override verdict if failing - ci_status = await self.gh_client.get_pr_checks(pr_number) - failed_checks = ci_status.get("failed_checks", []) + # Fallback: ensure CI failures block merge even if AI didn't factor it in + # (CI status was already passed to AI via followup_context.ci_status) + failed_checks = followup_context.ci_status.get("failed_checks", []) if failed_checks: print( f"[Followup] CI checks failing: {failed_checks}", @@ -724,6 +766,9 @@ class GitHubOrchestrator: # Save result await result.save(self.github_dir) + # Note: PR review memory is now saved by the Electron app after the review completes + # This ensures memory is saved to the embedded LadybugDB managed by the app + # Mark as reviewed with new commit SHA if result.reviewed_commit_sha: self.bot_detector.mark_reviewed(pr_number, result.reviewed_commit_sha) @@ -801,6 +846,13 @@ class GitHubOrchestrator: for check_name in failed_checks: blockers.append(f"CI Failed: {check_name}") + # Workflows awaiting approval block merging (fork PRs) + awaiting_approval = ci_status.get("awaiting_approval", 0) + if awaiting_approval > 0: + blockers.append( + f"Workflows Pending: {awaiting_approval} workflow(s) awaiting maintainer approval" + ) + # NEW: Verification failures block merging for f in verification_failures: note = f" - {f.verification_note}" if f.verification_note else "" @@ -842,6 +894,13 @@ class GitHubOrchestrator: f"Blocked: {len(failed_checks)} CI check(s) failing. " "Fix CI before merge." ) + # Workflows awaiting approval block merging + elif awaiting_approval > 0: + verdict = MergeVerdict.BLOCKED + reasoning = ( + f"Blocked: {awaiting_approval} workflow(s) awaiting approval. " + "Approve workflows on GitHub to run CI checks." + ) # NEW: Prioritize verification failures elif verification_failures: verdict = MergeVerdict.BLOCKED diff --git a/apps/backend/runners/github/services/parallel_followup_reviewer.py b/apps/backend/runners/github/services/parallel_followup_reviewer.py index d7536a9e..2728b324 100644 --- a/apps/backend/runners/github/services/parallel_followup_reviewer.py +++ b/apps/backend/runners/github/services/parallel_followup_reviewer.py @@ -21,6 +21,9 @@ from __future__ import annotations import hashlib import logging import os +import shutil +import subprocess +import uuid from pathlib import Path from typing import TYPE_CHECKING @@ -32,6 +35,7 @@ from claude_agent_sdk import AgentDefinition try: from ...core.client import create_client from ...phase_config import get_thinking_budget + from ..context_gatherer import _validate_git_ref from ..gh_client import GHClient from ..models import ( GitHubRunnerConfig, @@ -44,6 +48,7 @@ try: from .pydantic_models import ParallelFollowupResponse from .sdk_utils import process_sdk_stream except (ImportError, ValueError, SystemError): + from context_gatherer import _validate_git_ref from core.client import create_client from gh_client import GHClient from models import ( @@ -64,6 +69,9 @@ logger = logging.getLogger(__name__) # Check if debug mode is enabled DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") +# Directory for PR review worktrees (shared with initial reviewer) +PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees" + # Severity mapping for AI responses _SEVERITY_MAPPING = { "critical": ReviewSeverity.CRITICAL, @@ -138,6 +146,122 @@ class ParallelFollowupReviewer: logger.warning(f"Prompt file not found: {prompt_file}") return "" + def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path: + """Create a temporary worktree at the PR head commit. + + Args: + head_sha: The commit SHA of the PR head (validated before use) + pr_number: The PR number for naming + + Returns: + Path to the created worktree + + Raises: + RuntimeError: If worktree creation fails + ValueError: If head_sha fails validation (command injection prevention) + """ + # SECURITY: Validate git ref before use in subprocess calls + if not _validate_git_ref(head_sha): + raise ValueError( + f"Invalid git ref: '{head_sha}'. " + "Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens." + ) + + worktree_name = f"pr-followup-{pr_number}-{uuid.uuid4().hex[:8]}" + worktree_dir = self.project_dir / PR_WORKTREE_DIR + + if DEBUG_MODE: + print(f"[Followup] DEBUG: project_dir={self.project_dir}", flush=True) + print(f"[Followup] DEBUG: worktree_dir={worktree_dir}", flush=True) + print(f"[Followup] DEBUG: head_sha={head_sha}", flush=True) + + worktree_dir.mkdir(parents=True, exist_ok=True) + worktree_path = worktree_dir / worktree_name + + if DEBUG_MODE: + print(f"[Followup] DEBUG: worktree_path={worktree_path}", flush=True) + + # Fetch the commit if not available locally (handles fork PRs) + fetch_result = subprocess.run( + ["git", "fetch", "origin", head_sha], + cwd=self.project_dir, + capture_output=True, + text=True, + timeout=60, + ) + if DEBUG_MODE: + print( + f"[Followup] DEBUG: fetch returncode={fetch_result.returncode}", + flush=True, + ) + + # Create detached worktree at the PR commit + result = subprocess.run( + ["git", "worktree", "add", "--detach", str(worktree_path), head_sha], + cwd=self.project_dir, + capture_output=True, + text=True, + timeout=120, + ) + + if DEBUG_MODE: + print( + f"[Followup] DEBUG: worktree add returncode={result.returncode}", + flush=True, + ) + if result.stderr: + print( + f"[Followup] DEBUG: worktree add stderr={result.stderr[:200]}", + flush=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to create worktree: {result.stderr}") + + logger.info(f"[Followup] Created worktree at {worktree_path}") + return worktree_path + + def _cleanup_pr_worktree(self, worktree_path: Path) -> None: + """Remove a temporary PR review worktree with fallback chain. + + Args: + worktree_path: Path to the worktree to remove + """ + if not worktree_path or not worktree_path.exists(): + return + + if DEBUG_MODE: + print( + f"[Followup] DEBUG: Cleaning up worktree at {worktree_path}", + flush=True, + ) + + # Try 1: git worktree remove + result = subprocess.run( + ["git", "worktree", "remove", "--force", str(worktree_path)], + cwd=self.project_dir, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + logger.info(f"[Followup] Cleaned up worktree: {worktree_path.name}") + return + + # Try 2: shutil.rmtree fallback + try: + shutil.rmtree(worktree_path, ignore_errors=True) + subprocess.run( + ["git", "worktree", "prune"], + cwd=self.project_dir, + capture_output=True, + timeout=30, + ) + logger.warning(f"[Followup] Used shutil fallback for: {worktree_path.name}") + except Exception as e: + logger.error(f"[Followup] Failed to cleanup worktree {worktree_path}: {e}") + def _define_specialist_agents(self) -> dict[str, AgentDefinition]: """ Define specialist agents for follow-up review. @@ -267,6 +391,44 @@ class ParallelFollowupReviewer: return "\n\n---\n\n".join(ai_content) + def _format_ci_status(self, context: FollowupReviewContext) -> str: + """Format CI status for the prompt.""" + ci_status = context.ci_status + if not ci_status: + return "CI status not available." + + passing = ci_status.get("passing", 0) + failing = ci_status.get("failing", 0) + pending = ci_status.get("pending", 0) + failed_checks = ci_status.get("failed_checks", []) + awaiting_approval = ci_status.get("awaiting_approval", 0) + + lines = [] + + # Overall status + if failing > 0: + lines.append(f"⚠️ **{failing} CI check(s) FAILING** - PR cannot be merged") + elif pending > 0: + lines.append(f"⏳ **{pending} CI check(s) pending** - Wait for completion") + elif passing > 0: + lines.append(f"✅ **All {passing} CI check(s) passing**") + else: + lines.append("No CI checks configured") + + # List failed checks + if failed_checks: + lines.append("\n**Failed checks:**") + for check in failed_checks: + lines.append(f" - ❌ {check}") + + # Awaiting approval (fork PRs) + if awaiting_approval > 0: + lines.append( + f"\n⏸️ **{awaiting_approval} workflow(s) awaiting maintainer approval** (fork PR)" + ) + + return "\n".join(lines) + def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str: """Build full prompt for orchestrator with follow-up context.""" # Load orchestrator prompt @@ -279,6 +441,7 @@ class ParallelFollowupReviewer: commits = self._format_commits(context) contributor_comments = self._format_comments(context) ai_reviews = self._format_ai_reviews(context) + ci_status = self._format_ci_status(context) # Truncate diff if too long MAX_DIFF_CHARS = 100_000 @@ -297,6 +460,9 @@ class ParallelFollowupReviewer: **New Commits:** {len(context.commits_since_review)} **Files Changed:** {len(context.files_changed_since_review)} +### CI Status (CRITICAL - Must Factor Into Verdict) +{ci_status} + ### Previous Review Summary {context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."} @@ -325,6 +491,7 @@ class ParallelFollowupReviewer: 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. +**CRITICAL: Your verdict MUST account for CI status. Failing CI = BLOCKED verdict.** """ return base_prompt + followup_context @@ -343,6 +510,9 @@ The SDK will run invoked agents in parallel automatically. f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}" ) + # Track worktree for cleanup + worktree_path: Path | None = None + try: self._report_progress( "orchestrating", @@ -354,13 +524,48 @@ The SDK will run invoked agents in parallel automatically. # Build orchestrator prompt prompt = self._build_orchestrator_prompt(context) - # Get project root + # Get project root - default to local checkout project_root = ( self.project_dir.parent.parent if self.project_dir.name == "backend" else self.project_dir ) + # Create temporary worktree at PR head commit for isolated review + # This ensures agents read from the correct PR state, not the current checkout + head_sha = context.current_commit_sha + if head_sha and _validate_git_ref(head_sha): + try: + if DEBUG_MODE: + print( + f"[Followup] DEBUG: Creating worktree for head_sha={head_sha}", + flush=True, + ) + worktree_path = self._create_pr_worktree( + head_sha, context.pr_number + ) + project_root = worktree_path + print( + f"[Followup] Using worktree at {worktree_path.name} for PR review", + flush=True, + ) + except Exception as e: + if DEBUG_MODE: + print( + f"[Followup] DEBUG: Worktree creation FAILED: {e}", + flush=True, + ) + logger.warning( + f"[ParallelFollowup] Worktree creation failed, " + f"falling back to local checkout: {e}" + ) + # Fallback to original behavior if worktree creation fails + else: + logger.warning( + f"[ParallelFollowup] Invalid or missing head_sha '{head_sha}', " + "using local checkout" + ) + # 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" @@ -567,6 +772,10 @@ The SDK will run invoked agents in parallel automatically. is_followup_review=True, reviewed_commit_sha=context.current_commit_sha, ) + finally: + # Always cleanup worktree, even on error + if worktree_path: + self._cleanup_pr_worktree(worktree_path) def _parse_structured_output( self, data: dict, context: FollowupReviewContext diff --git a/apps/backend/security/__init__.py b/apps/backend/security/__init__.py index 9b389373..b26311d2 100644 --- a/apps/backend/security/__init__.py +++ b/apps/backend/security/__init__.py @@ -62,7 +62,9 @@ from .validator import ( validate_chmod_command, validate_dropdb_command, validate_dropuser_command, + validate_git_command, validate_git_commit, + validate_git_config, validate_init_script, validate_kill_command, validate_killall_command, @@ -93,7 +95,9 @@ __all__ = [ "validate_chmod_command", "validate_rm_command", "validate_init_script", + "validate_git_command", "validate_git_commit", + "validate_git_config", "validate_dropdb_command", "validate_dropuser_command", "validate_psql_command", diff --git a/apps/backend/security/git_validators.py b/apps/backend/security/git_validators.py index 5a75ad39..5c21d329 100644 --- a/apps/backend/security/git_validators.py +++ b/apps/backend/security/git_validators.py @@ -2,7 +2,9 @@ Git Validators ============== -Validators for git operations (commit with secret scanning). +Validators for git operations: +- Commit with secret scanning +- Config protection (prevent setting test users) """ import shlex @@ -10,8 +12,203 @@ from pathlib import Path from .validation_models import ValidationResult +# ============================================================================= +# BLOCKED GIT CONFIG PATTERNS +# ============================================================================= -def validate_git_commit(command_string: str) -> ValidationResult: +# Git config keys that agents must NOT modify +# These are identity settings that should inherit from the user's global config +# +# NOTE: This validation covers command-line arguments (git config, git -c). +# Environment variables (GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME, +# GIT_COMMITTER_EMAIL) are NOT validated here as they require pre-execution +# environment filtering, which is handled at the sandbox/hook level. +BLOCKED_GIT_CONFIG_KEYS = { + "user.name", + "user.email", + "author.name", + "author.email", + "committer.name", + "committer.email", +} + + +def validate_git_config(command_string: str) -> ValidationResult: + """ + Validate git config commands - block identity changes. + + Agents should not set user.name, user.email, etc. as this: + 1. Breaks commit attribution + 2. Can create fake "Test User" identities + 3. Overrides the user's legitimate git identity + + Args: + command_string: The full git command string + + Returns: + Tuple of (is_valid, error_message) + """ + try: + tokens = shlex.split(command_string) + except ValueError: + return False, "Could not parse git command" # Fail closed on parse errors + + if len(tokens) < 2 or tokens[0] != "git" or tokens[1] != "config": + return True, "" # Not a git config command + + # Check for read-only operations first - these are always allowed + # --get, --get-all, --get-regexp, --list are all read operations + read_only_flags = {"--get", "--get-all", "--get-regexp", "--list", "-l"} + for token in tokens[2:]: + if token in read_only_flags: + return True, "" # Read operation, allow it + + # Extract the config key from the command + # git config [options] [value] - key is typically after config and any options + config_key = None + for token in tokens[2:]: + # Skip options (start with -) + if token.startswith("-"): + continue + # First non-option token is the config key + config_key = token.lower() + break + + if not config_key: + return True, "" # No config key specified (e.g., git config --list) + + # Check if the exact config key is blocked + for blocked_key in BLOCKED_GIT_CONFIG_KEYS: + if config_key == blocked_key: + return False, ( + f"BLOCKED: Cannot modify git identity configuration\n\n" + f"You attempted to set '{blocked_key}' which is not allowed.\n\n" + f"WHY: Git identity (user.name, user.email) must inherit from the user's " + f"global git configuration. Setting fake identities like 'Test User' breaks " + f"commit attribution and causes serious issues.\n\n" + f"WHAT TO DO: Simply commit without setting any user configuration. " + f"The repository will use the correct identity automatically." + ) + + return True, "" + + +def validate_git_inline_config(tokens: list[str]) -> ValidationResult: + """ + Check for blocked config keys passed via git -c flag. + + Git allows inline config with: git -c key=value + This bypasses 'git config' validation, so we must check all git commands + for -c flags containing blocked identity keys. + + Args: + tokens: Parsed command tokens + + Returns: + Tuple of (is_valid, error_message) + """ + i = 1 # Start after 'git' + while i < len(tokens): + token = tokens[i] + + # Check for -c flag (can be "-c key=value" or "-c" "key=value") + if token == "-c": + # Next token should be the key=value + if i + 1 < len(tokens): + config_pair = tokens[i + 1] + # Extract the key from key=value + if "=" in config_pair: + config_key = config_pair.split("=", 1)[0].lower() + if config_key in BLOCKED_GIT_CONFIG_KEYS: + return False, ( + f"BLOCKED: Cannot set git identity via -c flag\n\n" + f"You attempted to use '-c {config_pair}' which sets a blocked " + f"identity configuration.\n\n" + f"WHY: Git identity (user.name, user.email) must inherit from the " + f"user's global git configuration. Setting fake identities breaks " + f"commit attribution and causes serious issues.\n\n" + f"WHAT TO DO: Remove the -c flag and commit normally. " + f"The repository will use the correct identity automatically." + ) + i += 2 # Skip -c and its value + continue + elif token.startswith("-c"): + # Handle -ckey=value format (no space) + config_pair = token[2:] # Remove "-c" prefix + if "=" in config_pair: + config_key = config_pair.split("=", 1)[0].lower() + if config_key in BLOCKED_GIT_CONFIG_KEYS: + return False, ( + f"BLOCKED: Cannot set git identity via -c flag\n\n" + f"You attempted to use '{token}' which sets a blocked " + f"identity configuration.\n\n" + f"WHY: Git identity (user.name, user.email) must inherit from the " + f"user's global git configuration. Setting fake identities breaks " + f"commit attribution and causes serious issues.\n\n" + f"WHAT TO DO: Remove the -c flag and commit normally. " + f"The repository will use the correct identity automatically." + ) + + i += 1 + + return True, "" + + +def validate_git_command(command_string: str) -> ValidationResult: + """ + Main git validator that checks all git security rules. + + Currently validates: + - git -c: Block identity changes via inline config on ANY git command + - git config: Block identity changes + - git commit: Run secret scanning + + Args: + command_string: The full git command string + + Returns: + Tuple of (is_valid, error_message) + """ + try: + tokens = shlex.split(command_string) + except ValueError: + return False, "Could not parse git command" + + if not tokens or tokens[0] != "git": + return True, "" + + if len(tokens) < 2: + return True, "" # Just "git" with no subcommand + + # Check for blocked -c flags on ANY git command (security bypass prevention) + is_valid, error_msg = validate_git_inline_config(tokens) + if not is_valid: + return is_valid, error_msg + + # Find the actual subcommand (skip global options like -c, -C, --git-dir, etc.) + subcommand = None + for token in tokens[1:]: + # Skip options and their values + if token.startswith("-"): + continue + subcommand = token + break + + if not subcommand: + return True, "" # No subcommand found + + # Check git config commands + if subcommand == "config": + return validate_git_config(command_string) + + # Check git commit commands (secret scanning) + if subcommand == "commit": + return validate_git_commit_secrets(command_string) + + return True, "" + + +def validate_git_commit_secrets(command_string: str) -> ValidationResult: """ Validate git commit commands - run secret scan before allowing commit. @@ -99,3 +296,8 @@ def validate_git_commit(command_string: str) -> ValidationResult: ) return False, "\n".join(error_lines) + + +# Backwards compatibility alias - the registry uses this name +# Now delegates to the comprehensive validator +validate_git_commit = validate_git_command diff --git a/apps/backend/security/validator.py b/apps/backend/security/validator.py index 7727f012..c1ca2898 100644 --- a/apps/backend/security/validator.py +++ b/apps/backend/security/validator.py @@ -33,7 +33,11 @@ from .filesystem_validators import ( validate_init_script, validate_rm_command, ) -from .git_validators import validate_git_commit +from .git_validators import ( + validate_git_command, + validate_git_commit, + validate_git_config, +) from .process_validators import ( validate_kill_command, validate_killall_command, @@ -60,6 +64,8 @@ __all__ = [ "validate_init_script", # Git validators "validate_git_commit", + "validate_git_command", + "validate_git_config", # Database validators "validate_dropdb_command", "validate_dropuser_command", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 5c081348..07b19c2d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -84,6 +84,7 @@ "electron-updater": "^6.6.2", "i18next": "^25.7.3", "lucide-react": "^0.562.0", + "minimatch": "^10.1.1", "motion": "^12.23.26", "proper-lockfile": "^4.1.2", "react": "^19.2.3", @@ -107,6 +108,7 @@ "@tailwindcss/postcss": "^4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", + "@types/minimatch": "^5.1.2", "@types/node": "^25.0.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/apps/frontend/src/__mocks__/electron.ts b/apps/frontend/src/__mocks__/electron.ts index 39f45801..78e9b994 100644 --- a/apps/frontend/src/__mocks__/electron.ts +++ b/apps/frontend/src/__mocks__/electron.ts @@ -56,7 +56,8 @@ export const ipcRenderer = { on: vi.fn(), once: vi.fn(), removeListener: vi.fn(), - removeAllListeners: vi.fn() + removeAllListeners: vi.fn(), + setMaxListeners: vi.fn() }; // Mock BrowserWindow diff --git a/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts b/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts index 641f8e96..432c5f36 100644 --- a/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts +++ b/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts @@ -11,7 +11,8 @@ const mockIpcRenderer = { on: vi.fn(), once: vi.fn(), removeListener: vi.fn(), - removeAllListeners: vi.fn() + removeAllListeners: vi.fn(), + setMaxListeners: vi.fn() }; // Mock contextBridge diff --git a/apps/frontend/src/main/agent/env-utils.test.ts b/apps/frontend/src/main/agent/env-utils.test.ts index 1d10e791..6a5d42c5 100644 --- a/apps/frontend/src/main/agent/env-utils.test.ts +++ b/apps/frontend/src/main/agent/env-utils.test.ts @@ -12,6 +12,7 @@ describe('getOAuthModeClearVars', () => { const result = getOAuthModeClearVars({}); expect(result).toEqual({ + ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_MODEL: '', @@ -25,6 +26,7 @@ describe('getOAuthModeClearVars', () => { const result = getOAuthModeClearVars({}); // Verify all known ANTHROPIC_* vars are cleared + expect(result.ANTHROPIC_API_KEY).toBe(''); expect(result.ANTHROPIC_AUTH_TOKEN).toBe(''); expect(result.ANTHROPIC_BASE_URL).toBe(''); expect(result.ANTHROPIC_MODEL).toBe(''); @@ -85,6 +87,7 @@ describe('getOAuthModeClearVars', () => { // Should treat null as OAuth mode and return clearing vars expect(result).toEqual({ + ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_MODEL: '', @@ -101,6 +104,7 @@ describe('getOAuthModeClearVars', () => { expect(result1).toEqual(result2); // Use specific expected keys instead of magic number const expectedKeys = [ + 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', @@ -117,6 +121,7 @@ describe('getOAuthModeClearVars', () => { // Should treat as OAuth mode since no ANTHROPIC_* keys present expect(result).toEqual({ + ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_MODEL: '', diff --git a/apps/frontend/src/main/agent/env-utils.ts b/apps/frontend/src/main/agent/env-utils.ts index 5de716ef..ba384dfa 100644 --- a/apps/frontend/src/main/agent/env-utils.ts +++ b/apps/frontend/src/main/agent/env-utils.ts @@ -28,7 +28,12 @@ export function getOAuthModeClearVars(apiProfileEnv: Record): Re // In OAuth mode (no API profile), clear all ANTHROPIC_* vars // Setting to empty string ensures they override any values from process.env // Python's `if token:` checks treat empty strings as falsy + // + // IMPORTANT: ANTHROPIC_API_KEY is included to prevent Claude Code from using + // API keys that may be present in the shell environment instead of OAuth tokens. + // Without clearing this, Claude Code would show "Claude API" instead of "Claude Max". return { + ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_MODEL: '', diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index c1620b43..98f1f824 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -289,7 +289,13 @@ async function fetchLatestStableRelease(): Promise { // Validate HTTP status code const statusCode = response.statusCode; if (statusCode !== 200) { - console.error(`[app-updater] GitHub API error: HTTP ${statusCode}`); + // Sanitize statusCode to prevent log injection + // Convert to number and validate range to ensure it's a valid HTTP status code + const numericCode = Number(statusCode); + const safeStatusCode = (Number.isInteger(numericCode) && numericCode >= 100 && numericCode < 600) + ? String(numericCode) + : 'unknown'; + console.error(`[app-updater] GitHub API error: HTTP ${safeStatusCode}`); if (statusCode === 403) { console.error('[app-updater] Rate limit may have been exceeded'); } else if (statusCode === 404) { @@ -333,7 +339,10 @@ async function fetchLatestStableRelease(): Promise { } const version = latestStable.tag_name.replace(/^v/, ''); - console.warn('[app-updater] Found latest stable release:', version); + // Sanitize version string for logging (remove control characters and limit length) + // eslint-disable-next-line no-control-regex + const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50); + console.warn('[app-updater] Found latest stable release:', safeVersion); resolve({ version, @@ -341,14 +350,18 @@ async function fetchLatestStableRelease(): Promise { releaseDate: latestStable.published_at }); } catch (e) { - console.error('[app-updater] Failed to parse releases JSON:', e); + // Sanitize error message for logging (prevent log injection from malformed JSON) + const safeError = e instanceof Error ? e.message : 'Unknown parse error'; + console.error('[app-updater] Failed to parse releases JSON:', safeError); resolve(null); } }); }); request.on('error', (error) => { - console.error('[app-updater] Failed to fetch releases:', error); + // Sanitize error message for logging (use only the message property) + const safeErrorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('[app-updater] Failed to fetch releases:', safeErrorMessage); resolve(null); }); diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index 027df15f..6d4a9b91 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -1,5 +1,4 @@ import { spawn } from 'child_process'; -import * as path from 'path'; import * as os from 'os'; import type { GitCommit } from '../../shared/types'; import { getProfileEnv } from '../rate-limit-detector'; diff --git a/apps/frontend/src/main/insights/config.ts b/apps/frontend/src/main/insights/config.ts index b34bf858..97e8a9a2 100644 --- a/apps/frontend/src/main/insights/config.ts +++ b/apps/frontend/src/main/insights/config.ts @@ -1,6 +1,5 @@ import path from 'path'; import { existsSync, readFileSync } from 'fs'; -import { app } from 'electron'; import { getProfileEnv } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; import { getOAuthModeClearVars } from '../agent/env-utils'; diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index cbe4a67b..db92b116 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron'; -import path from 'path'; -import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; +import { IPC_CHANNELS } from '../../shared/constants'; import type { SDKRateLimitInfo, Task, diff --git a/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts b/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts index 7d9ff082..751578da 100644 --- a/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts +++ b/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts @@ -65,6 +65,10 @@ const tempDirs: string[] = []; vi.mock('electron', () => ({ ipcMain: mockIpcMain, BrowserWindow: class {}, + app: { + getPath: vi.fn(() => '/tmp'), + on: vi.fn(), + }, })); vi.mock('../../../agent/agent-manager', () => ({ diff --git a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts index 94bfa627..187eaa5d 100644 --- a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts @@ -364,7 +364,15 @@ async function startAutoFix( // Create spec const taskDescription = buildInvestigationTask(issue.number, issue.title, issueContext); - const specData = await createSpecForIssue(project, issue.number, issue.title, taskDescription, issue.html_url, labels); + const specData = await createSpecForIssue( + project, + issue.number, + issue.title, + taskDescription, + issue.html_url, + labels, + project.settings?.mainBranch // Pass project's configured main branch + ); // Save auto-fix state const issuesDir = path.join(getGitHubDir(project), 'issues'); diff --git a/apps/frontend/src/main/ipc-handlers/github/import-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/import-handlers.ts index 8a38619e..9e2e5c05 100644 --- a/apps/frontend/src/main/ipc-handlers/github/import-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/import-handlers.ts @@ -66,7 +66,8 @@ ${issue.body || 'No description provided.'} issue.title, description, issue.html_url, - labelNames + labelNames, + project.settings?.mainBranch // Pass project's configured main branch ); // Start spec creation with the existing spec directory diff --git a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts index 4f5a36d4..7ddae6e5 100644 --- a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts @@ -148,7 +148,8 @@ export function registerInvestigateIssue( issue.title, taskDescription, issue.html_url, - labels + labels, + project.settings?.mainBranch // Pass project's configured main branch ); // NOTE: We intentionally do NOT call agentManager.startSpecCreation() here diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 30be9c66..123697a6 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -16,6 +16,7 @@ import { IPC_CHANNELS, MODEL_ID_MAP, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THI import { getGitHubConfig, githubFetch } from './utils'; import { readSettingsFile } from '../../settings-utils'; import { getAugmentedEnv } from '../../env-utils'; +import { getMemoryService, getDefaultDbPath } from '../../memory-service'; import type { Project, AppSettings } from '../../../shared/types'; import { createContextLogger } from './utils/logger'; import { withProjectOrNull } from './utils/project-middleware'; @@ -133,6 +134,159 @@ export interface NewCommitsCheck { hasCommitsAfterPosting?: boolean; } +/** + * PR review memory stored in the memory layer + * Represents key insights and learnings from a PR review + */ +export interface PRReviewMemory { + prNumber: number; + repo: string; + verdict: string; + timestamp: string; + summary: { + verdict: string; + verdict_reasoning?: string; + finding_counts?: Record; + total_findings?: number; + blockers?: string[]; + risk_assessment?: Record; + }; + keyFindings: Array<{ + severity: string; + category: string; + title: string; + description: string; + file: string; + line: number; + }>; + patterns: string[]; + gotchas: string[]; + isFollowup: boolean; +} + +/** + * Save PR review insights to the Electron memory layer (LadybugDB) + * + * Called after a PR review completes to persist learnings for cross-session context. + * Extracts key findings, patterns, and gotchas from the review result. + * + * @param result The completed PR review result + * @param repo Repository name (owner/repo) + * @param isFollowup Whether this is a follow-up review + */ +async function savePRReviewToMemory( + result: PRReviewResult, + repo: string, + isFollowup: boolean = false +): Promise { + const settings = readSettingsFile(); + if (!settings?.memoryEnabled) { + debugLog('Memory not enabled, skipping PR review memory save'); + return; + } + + try { + const memoryService = getMemoryService({ + dbPath: getDefaultDbPath(), + database: 'auto_claude_memory', + }); + + // Build the memory content with comprehensive insights + // We want to capture ALL meaningful findings so the AI can learn from patterns + + // Prioritize findings: critical > high > medium > low + // Include all critical/high, top 5 medium, top 3 low + const criticalFindings = result.findings.filter(f => f.severity === 'critical'); + const highFindings = result.findings.filter(f => f.severity === 'high'); + const mediumFindings = result.findings.filter(f => f.severity === 'medium').slice(0, 5); + const lowFindings = result.findings.filter(f => f.severity === 'low').slice(0, 3); + + const keyFindingsToSave = [ + ...criticalFindings, + ...highFindings, + ...mediumFindings, + ...lowFindings, + ].map(f => ({ + severity: f.severity, + category: f.category, + title: f.title, + description: f.description.substring(0, 500), // Truncate for storage + file: f.file, + line: f.line, + })); + + // Extract gotchas: security issues, critical bugs, and common mistakes + const gotchaCategories = ['security', 'error_handling', 'data_validation', 'race_condition']; + const gotchasToSave = result.findings + .filter(f => + f.severity === 'critical' || + f.severity === 'high' || + gotchaCategories.includes(f.category?.toLowerCase() || '') + ) + .map(f => `[${f.category}] ${f.title}: ${f.description.substring(0, 300)}`); + + // Extract patterns: group findings by category to identify recurring issues + const categoryGroups = result.findings.reduce((acc, f) => { + const cat = f.category || 'general'; + acc[cat] = (acc[cat] || 0) + 1; + return acc; + }, {} as Record); + + // Patterns are categories that appear multiple times (indicates a systematic issue) + const patternsToSave = Object.entries(categoryGroups) + .filter(([_, count]) => count >= 2) + .map(([category, count]) => `${category}: ${count} occurrences`); + + const memoryContent: PRReviewMemory = { + prNumber: result.prNumber, + repo, + verdict: result.overallStatus || 'unknown', + timestamp: new Date().toISOString(), + summary: { + verdict: result.overallStatus || 'unknown', + finding_counts: { + critical: criticalFindings.length, + high: highFindings.length, + medium: result.findings.filter(f => f.severity === 'medium').length, + low: result.findings.filter(f => f.severity === 'low').length, + }, + total_findings: result.findings.length, + }, + keyFindings: keyFindingsToSave, + patterns: patternsToSave, + gotchas: gotchasToSave, + isFollowup, + }; + + // Add follow-up specific info if applicable + if (isFollowup && result.resolvedFindings && result.unresolvedFindings) { + memoryContent.summary.verdict_reasoning = + `Resolved: ${result.resolvedFindings.length}, Unresolved: ${result.unresolvedFindings.length}`; + } + + // Save to memory as a pr_review episode + const episodeName = `PR #${result.prNumber} ${isFollowup ? 'Follow-up ' : ''}Review - ${repo}`; + const saveResult = await memoryService.addEpisode( + episodeName, + memoryContent, + 'pr_review', + `pr_review_${repo.replace('/', '_')}` + ); + + if (saveResult.success) { + debugLog('PR review saved to memory', { prNumber: result.prNumber, episodeId: saveResult.id }); + } else { + debugLog('Failed to save PR review to memory', { error: saveResult.error }); + } + + } catch (error) { + // Don't fail the review if memory save fails + debugLog('Error saving PR review to memory', { + error: error instanceof Error ? error.message : error + }); + } +} + /** * PR data from GitHub API */ @@ -690,6 +844,12 @@ async function runPRReview( // Finalize logs with success logCollector.finalize(true); + + // Save PR review insights to memory (async, non-blocking) + savePRReviewToMemory(result.data!, repo, false).catch(err => { + debugLog('Failed to save PR review to memory', { error: err.message }); + }); + return result.data!; } finally { // Clean up the registry when done (success or error) @@ -706,11 +866,11 @@ export function registerPRHandlers( ): void { debugLog('Registering PR handlers'); - // List open PRs + // List open PRs with pagination support ipcMain.handle( IPC_CHANNELS.GITHUB_PR_LIST, - async (_, projectId: string): Promise => { - debugLog('listPRs handler called', { projectId }); + async (_, projectId: string, page: number = 1): Promise => { + debugLog('listPRs handler called', { projectId, page }); const result = await withProjectOrNull(projectId, async (project) => { const config = getGitHubConfig(project); if (!config) { @@ -719,9 +879,10 @@ export function registerPRHandlers( } try { + // Use pagination: per_page=100 (GitHub max), page=1,2,3... const prs = await githubFetch( config.token, - `/repos/${config.repo}/pulls?state=open&per_page=50` + `/repos/${config.repo}/pulls?state=open&per_page=100&page=${page}` ) as Array<{ number: number; title: string; @@ -739,7 +900,7 @@ export function registerPRHandlers( html_url: string; }>; - debugLog('Fetched PRs', { count: prs.length }); + debugLog('Fetched PRs', { count: prs.length, page }); return prs.map(pr => ({ number: pr.number, title: pr.title, @@ -873,6 +1034,23 @@ export function registerPRHandlers( } ); + // Batch get saved reviews - more efficient than individual calls + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH, + async (_, projectId: string, prNumbers: number[]): Promise> => { + debugLog('getReviewsBatch handler called', { projectId, count: prNumbers.length }); + const result = await withProjectOrNull(projectId, async (project) => { + const reviews: Record = {}; + for (const prNumber of prNumbers) { + reviews[prNumber] = getReviewResult(project, prNumber); + } + debugLog('Batch loaded reviews', { count: Object.values(reviews).filter(r => r !== null).length }); + return reviews; + }); + return result ?? {}; + } + ); + // Get PR review logs ipcMain.handle( IPC_CHANNELS.GITHUB_PR_GET_LOGS, @@ -976,8 +1154,8 @@ export function registerPRHandlers( // Post review to GitHub ipcMain.handle( IPC_CHANNELS.GITHUB_PR_POST_REVIEW, - async (_, projectId: string, prNumber: number, selectedFindingIds?: string[]): Promise => { - debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length }); + async (_, projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise => { + debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length, forceApprove: options?.forceApprove }); const postResult = await withProjectOrNull(projectId, async (project) => { const result = getReviewResult(project, prNumber); if (!result) { @@ -1000,36 +1178,69 @@ export function registerPRHandlers( debugLog('Posting findings', { total: result.findings.length, selected: findings.length }); - // Build review body - let body = `## 🤖 Auto Claude PR Review\n\n${result.summary}\n\n`; + // Build review body - different format for auto-approve with suggestions + let body: string; - if (findings.length > 0) { - // Show selected count vs total if filtered - const countText = selectedSet - ? `${findings.length} selected of ${result.findings.length} total` - : `${findings.length} total`; - body += `### Findings (${countText})\n\n`; + if (options?.forceApprove) { + // Auto-approve format: clean approval message with optional suggestions + body = `## ✅ Auto Claude Review - APPROVED\n\n`; + body += `**Status:** Ready to Merge\n\n`; + body += `**Summary:** ${result.summary}\n\n`; - for (const f of findings) { - const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪'; - body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`; - body += `📁 \`${f.file}:${f.line}\`\n\n`; - body += `${f.description}\n\n`; - // Only show suggested fix if it has actual content - const suggestedFix = f.suggestedFix?.trim(); - if (suggestedFix) { - body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`; + if (findings.length > 0) { + body += `---\n\n`; + body += `### 💡 Suggestions (${findings.length})\n\n`; + body += `*These are non-blocking suggestions for consideration:*\n\n`; + + for (const f of findings) { + const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪'; + body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`; + body += `📁 \`${f.file}:${f.line}\`\n\n`; + body += `${f.description}\n\n`; + const suggestedFix = f.suggestedFix?.trim(); + if (suggestedFix) { + body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`; + } } } + + body += `---\n*This automated review found no blocking issues. The PR can be safely merged.*\n\n`; + body += `*Generated by Auto Claude*`; } else { - body += `*No findings selected for this review.*\n\n`; + // Standard review format + body = `## 🤖 Auto Claude PR Review\n\n${result.summary}\n\n`; + + if (findings.length > 0) { + // Show selected count vs total if filtered + const countText = selectedSet + ? `${findings.length} selected of ${result.findings.length} total` + : `${findings.length} total`; + body += `### Findings (${countText})\n\n`; + + for (const f of findings) { + const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪'; + body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`; + body += `📁 \`${f.file}:${f.line}\`\n\n`; + body += `${f.description}\n\n`; + // Only show suggested fix if it has actual content + const suggestedFix = f.suggestedFix?.trim(); + if (suggestedFix) { + body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`; + } + } + } else { + body += `*No findings selected for this review.*\n\n`; + } + + body += `---\n*This review was generated by Auto Claude.*`; } - body += `---\n*This review was generated by Auto Claude.*`; - - // Determine review status based on selected findings + // Determine review status based on selected findings (or force approve) let overallStatus = result.overallStatus; - if (selectedSet) { + if (options?.forceApprove) { + // Force approve regardless of findings + overallStatus = 'approve'; + } else if (selectedSet) { const hasBlocker = findings.some(f => f.severity === 'critical' || f.severity === 'high'); overallStatus = hasBlocker ? 'request_changes' : (findings.length > 0 ? 'comment' : 'approve'); } @@ -1549,6 +1760,11 @@ export function registerPRHandlers( // Finalize logs with success logCollector.finalize(true); + // Save follow-up PR review insights to memory (async, non-blocking) + savePRReviewToMemory(result.data!, repo, true).catch(err => { + debugLog('Failed to save follow-up PR review to memory', { error: err.message }); + }); + debugLog('Follow-up review completed', { prNumber, findingsCount: result.data?.findings.length }); sendProgress({ phase: 'complete', @@ -1579,5 +1795,226 @@ export function registerPRHandlers( } ); + // Get workflows awaiting approval for a PR (fork PRs) + ipcMain.handle( + IPC_CHANNELS.GITHUB_WORKFLOWS_AWAITING_APPROVAL, + async (_, projectId: string, prNumber: number): Promise<{ + awaiting_approval: number; + workflow_runs: Array<{ id: number; name: string; html_url: string; workflow_name: string }>; + can_approve: boolean; + error?: string; + }> => { + debugLog('getWorkflowsAwaitingApproval handler called', { projectId, prNumber }); + const result = await withProjectOrNull(projectId, async (project) => { + const config = getGitHubConfig(project); + if (!config) { + return { awaiting_approval: 0, workflow_runs: [], can_approve: false, error: 'No GitHub config' }; + } + + try { + // First get the PR's head SHA + const prData = await githubFetch( + config.token, + `/repos/${config.repo}/pulls/${prNumber}` + ) as { head?: { sha?: string } }; + + const headSha = prData?.head?.sha; + if (!headSha) { + return { awaiting_approval: 0, workflow_runs: [], can_approve: false }; + } + + // Query workflow runs with action_required status + const runsData = await githubFetch( + config.token, + `/repos/${config.repo}/actions/runs?status=action_required&per_page=100` + ) as { workflow_runs?: Array<{ id: number; name: string; html_url: string; head_sha: string; workflow?: { name?: string } }> }; + + const allRuns = runsData?.workflow_runs || []; + + // Filter to only runs for this PR's head SHA + const prRuns = allRuns + .filter(run => run.head_sha === headSha) + .map(run => ({ + id: run.id, + name: run.name, + html_url: run.html_url, + workflow_name: run.workflow?.name || 'Unknown', + })); + + debugLog('Found workflows awaiting approval', { prNumber, count: prRuns.length }); + + return { + awaiting_approval: prRuns.length, + workflow_runs: prRuns, + can_approve: true, // Assume token has permission; will fail if not + }; + } catch (error) { + debugLog('Failed to get workflows awaiting approval', { prNumber, error: error instanceof Error ? error.message : error }); + return { + awaiting_approval: 0, + workflow_runs: [], + can_approve: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + return result ?? { awaiting_approval: 0, workflow_runs: [], can_approve: false }; + } + ); + + // Approve a workflow run + ipcMain.handle( + IPC_CHANNELS.GITHUB_WORKFLOW_APPROVE, + async (_, projectId: string, runId: number): Promise => { + debugLog('approveWorkflow handler called', { projectId, runId }); + const result = await withProjectOrNull(projectId, async (project) => { + const config = getGitHubConfig(project); + if (!config) { + debugLog('No GitHub config found'); + return false; + } + + try { + // Approve the workflow run + await githubFetch( + config.token, + `/repos/${config.repo}/actions/runs/${runId}/approve`, + { method: 'POST' } + ); + + debugLog('Workflow approved successfully', { runId }); + return true; + } catch (error) { + debugLog('Failed to approve workflow', { runId, error: error instanceof Error ? error.message : error }); + return false; + } + }); + + return result ?? false; + } + ); + + // Get PR review memories from the memory layer + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_MEMORY_GET, + async (_, projectId: string, limit: number = 10): Promise => { + debugLog('getPRReviewMemories handler called', { projectId, limit }); + const result = await withProjectOrNull(projectId, async (project) => { + const memoryDir = path.join(getGitHubDir(project), 'memory', project.name || 'unknown'); + const memories: PRReviewMemory[] = []; + + // Try to load from file-based storage + try { + const indexPath = path.join(memoryDir, 'reviews_index.json'); + if (!fs.existsSync(indexPath)) { + debugLog('No PR review memories found', { projectId }); + return []; + } + + const indexContent = fs.readFileSync(indexPath, 'utf-8'); + const index = JSON.parse(sanitizeNetworkData(indexContent)); + const reviews = index.reviews || []; + + // Load individual review memories + for (const entry of reviews.slice(0, limit)) { + try { + const reviewPath = path.join(memoryDir, `pr_${entry.pr_number}_review.json`); + if (fs.existsSync(reviewPath)) { + const reviewContent = fs.readFileSync(reviewPath, 'utf-8'); + const memory = JSON.parse(sanitizeNetworkData(reviewContent)); + memories.push({ + prNumber: memory.pr_number, + repo: memory.repo, + verdict: memory.summary?.verdict || 'unknown', + timestamp: memory.timestamp, + summary: memory.summary, + keyFindings: memory.key_findings || [], + patterns: memory.patterns || [], + gotchas: memory.gotchas || [], + isFollowup: memory.is_followup || false, + }); + } + } catch (err) { + debugLog('Failed to load PR review memory', { prNumber: entry.pr_number, error: err instanceof Error ? err.message : err }); + } + } + + debugLog('Loaded PR review memories', { count: memories.length }); + return memories; + } catch (error) { + debugLog('Failed to load PR review memories', { error: error instanceof Error ? error.message : error }); + return []; + } + }); + return result ?? []; + } + ); + + // Search PR review memories + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_MEMORY_SEARCH, + async (_, projectId: string, query: string, limit: number = 10): Promise => { + debugLog('searchPRReviewMemories handler called', { projectId, query, limit }); + const result = await withProjectOrNull(projectId, async (project) => { + const memoryDir = path.join(getGitHubDir(project), 'memory', project.name || 'unknown'); + const memories: PRReviewMemory[] = []; + const queryLower = query.toLowerCase(); + + // Search through file-based storage + try { + const indexPath = path.join(memoryDir, 'reviews_index.json'); + if (!fs.existsSync(indexPath)) { + return []; + } + + const indexContent = fs.readFileSync(indexPath, 'utf-8'); + const index = JSON.parse(sanitizeNetworkData(indexContent)); + const reviews = index.reviews || []; + + // Search individual review memories + for (const entry of reviews) { + try { + const reviewPath = path.join(memoryDir, `pr_${entry.pr_number}_review.json`); + if (fs.existsSync(reviewPath)) { + const reviewContent = fs.readFileSync(reviewPath, 'utf-8'); + + // Check if content matches query + if (reviewContent.toLowerCase().includes(queryLower)) { + const memory = JSON.parse(sanitizeNetworkData(reviewContent)); + memories.push({ + prNumber: memory.pr_number, + repo: memory.repo, + verdict: memory.summary?.verdict || 'unknown', + timestamp: memory.timestamp, + summary: memory.summary, + keyFindings: memory.key_findings || [], + patterns: memory.patterns || [], + gotchas: memory.gotchas || [], + isFollowup: memory.is_followup || false, + }); + } + } + + // Stop if we have enough + if (memories.length >= limit) { + break; + } + } catch (err) { + debugLog('Failed to search PR review memory', { prNumber: entry.pr_number, error: err instanceof Error ? err.message : err }); + } + } + + debugLog('Found matching PR review memories', { count: memories.length, query }); + return memories; + } catch (error) { + debugLog('Failed to search PR review memories', { error: error instanceof Error ? error.message : error }); + return []; + } + }); + return result ?? []; + } + ); + debugLog('PR handlers registered'); } diff --git a/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts b/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts index b233f59b..7e71b126 100644 --- a/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts @@ -8,6 +8,7 @@ import { AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; import type { Project, TaskMetadata } from '../../../shared/types'; import { withSpecNumberLock } from '../../utils/spec-number-lock'; import { debugLog } from './utils/logger'; +import { labelMatchesWholeWord } from '../shared/label-utils'; export interface SpecCreationData { specId: string; @@ -55,7 +56,14 @@ function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | } // Check for infrastructure labels - if (lowerLabels.some(l => l.includes('infrastructure') || l.includes('devops') || l.includes('deployment') || l.includes('ci') || l.includes('cd'))) { + // Use whole-word matching for 'ci' and 'cd' to avoid false positives like 'acid' or 'decide' + if (lowerLabels.some(l => + l.includes('infrastructure') || + l.includes('devops') || + l.includes('deployment') || + labelMatchesWholeWord(l, 'ci') || + labelMatchesWholeWord(l, 'cd') + )) { return 'infrastructure'; } @@ -89,7 +97,8 @@ export async function createSpecForIssue( issueTitle: string, taskDescription: string, githubUrl: string, - labels: string[] = [] + labels: string[] = [], + baseBranch?: string ): Promise { const specsBaseDir = getSpecsDir(project.autoBuildPath); const specsDir = path.join(project.path, specsBaseDir); @@ -144,7 +153,10 @@ export async function createSpecForIssue( sourceType: 'github', githubIssueNumber: issueNumber, githubUrl, - category + category, + // Store baseBranch for worktree creation and QA comparison + // This comes from project.settings.mainBranch or task-level override + ...(baseBranch && { baseBranch }) }; writeFileSync( path.join(specDir, 'task_metadata.json'), diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/import-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/import-handlers.ts index eea6215d..7b343efb 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/import-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/import-handlers.ts @@ -63,7 +63,7 @@ export function registerImportIssues(): void { ) as GitLabAPIIssue; // Create a spec/task from the issue - const task = await createSpecForIssue(project, apiIssue, config); + const task = await createSpecForIssue(project, apiIssue, config, project.settings?.mainBranch); if (task) { tasks.push(task); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts index 20b1a422..f383f032 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts @@ -158,7 +158,7 @@ export function registerInvestigateIssue( }); // Create spec for the issue - const task = await createSpecForIssue(project, issue, config); + const task = await createSpecForIssue(project, issue, config, project.settings?.mainBranch); if (!task) { sendError(getMainWindow, project.id, 'Failed to create task from issue'); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts index a8830ca3..c624a63f 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts @@ -7,6 +7,7 @@ import { mkdir, writeFile, readFile, stat } from 'fs/promises'; import path from 'path'; import type { Project } from '../../../shared/types'; import type { GitLabAPIIssue, GitLabConfig } from './types'; +import { labelMatchesWholeWord } from '../shared/label-utils'; /** * Simplified task info returned when creating a spec from a GitLab issue. @@ -60,6 +61,47 @@ function debugLog(message: string, data?: unknown): void { } } +/** + * Determine task category based on GitLab issue labels + * Maps to TaskCategory type from shared/types/task.ts + */ +function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | 'refactoring' | 'documentation' | 'security' | 'performance' | 'ui_ux' | 'infrastructure' | 'testing' { + const lowerLabels = labels.map(l => l.toLowerCase()); + + if (lowerLabels.some(l => l.includes('bug') || l.includes('defect') || l.includes('error') || l.includes('fix'))) { + return 'bug_fix'; + } + if (lowerLabels.some(l => l.includes('security') || l.includes('vulnerability') || l.includes('cve'))) { + return 'security'; + } + if (lowerLabels.some(l => l.includes('performance') || l.includes('optimization') || l.includes('speed'))) { + return 'performance'; + } + if (lowerLabels.some(l => l.includes('ui') || l.includes('ux') || l.includes('design') || l.includes('styling'))) { + return 'ui_ux'; + } + // Use whole-word matching for 'ci' and 'cd' to avoid false positives like 'acid' or 'decide' + if (lowerLabels.some(l => + l.includes('infrastructure') || + l.includes('devops') || + l.includes('deployment') || + labelMatchesWholeWord(l, 'ci') || + labelMatchesWholeWord(l, 'cd') + )) { + return 'infrastructure'; + } + if (lowerLabels.some(l => l.includes('test') || l.includes('testing') || l.includes('qa'))) { + return 'testing'; + } + if (lowerLabels.some(l => l.includes('refactor') || l.includes('cleanup') || l.includes('maintenance') || l.includes('chore') || l.includes('tech-debt') || l.includes('technical debt'))) { + return 'refactoring'; + } + if (lowerLabels.some(l => l.includes('documentation') || l.includes('docs'))) { + return 'documentation'; + } + return 'feature'; +} + function stripControlChars(value: string, allowNewlines: boolean): string { let sanitized = ''; for (let i = 0; i < value.length; i += 1) { @@ -258,7 +300,8 @@ async function pathExists(filePath: string): Promise { export async function createSpecForIssue( project: Project, issue: GitLabAPIIssue, - config: GitLabConfig + config: GitLabConfig, + baseBranch?: string ): Promise { try { // Validate and sanitize network data before writing to disk @@ -321,7 +364,7 @@ export async function createSpecForIssue( const taskContent = buildIssueContext(safeIssue, safeProject, config.instanceUrl); await writeFile(path.join(specDir, 'TASK.md'), taskContent, 'utf-8'); - // Create metadata.json + // Create metadata.json (legacy format for GitLab-specific data) const metadata = { source: 'gitlab', gitlab: { @@ -339,6 +382,21 @@ export async function createSpecForIssue( }; await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); + // Create task_metadata.json (consistent with GitHub format for backend compatibility) + const taskMetadata = { + sourceType: 'gitlab' as const, + gitlabIssueIid: safeIssue.iid, + gitlabUrl: safeIssue.web_url, + category: determineCategoryFromLabels(safeIssue.labels || []), + // Store baseBranch for worktree creation and QA comparison + ...(baseBranch && { baseBranch }) + }; + await writeFile( + path.join(specDir, 'task_metadata.json'), + JSON.stringify(taskMetadata, null, 2), + 'utf-8' + ); + debugLog('Created spec for issue:', { iid: safeIssue.iid, specDir }); // Return task info diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index 4ca0eb72..d752be8d 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -34,16 +34,56 @@ import { getEffectiveSourcePath } from '../updater/path-resolver'; // ============================================ /** - * Get list of git branches for a directory + * Get list of git branches for a directory (both local and remote) */ function getGitBranches(projectPath: string): string[] { try { - const result = execFileSync(getToolPath('git'), ['branch', '--list', '--format=%(refname:short)'], { + // First fetch to ensure we have latest remote refs + try { + execFileSync(getToolPath('git'), ['fetch', '--prune'], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 // 10 second timeout for fetch + }); + } catch { + // Fetch may fail if offline or no remote, continue with local refs + } + + // Get all branches (local + remote) using --all flag + const result = execFileSync(getToolPath('git'), ['branch', '--all', '--format=%(refname:short)'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - return result.trim().split('\n').filter(b => b.trim()); + + const branches = result.trim().split('\n') + .filter(b => b.trim()) + .map(b => { + // Remote branches come as "origin/branch-name", keep the full name + // but remove the "origin/" prefix for display while keeping it usable + return b.trim(); + }) + // Remove HEAD pointer entries like "origin/HEAD" + .filter(b => !b.endsWith('/HEAD')) + // Remove duplicates (local branch may exist alongside remote) + .filter((branch, index, self) => { + // If it's a remote branch (origin/x) and local version exists, keep local + if (branch.startsWith('origin/')) { + const localName = branch.replace('origin/', ''); + return !self.includes(localName); + } + return self.indexOf(branch) === index; + }); + + // Sort: local branches first, then remote branches + return branches.sort((a, b) => { + const aIsRemote = a.startsWith('origin/'); + const bIsRemote = b.startsWith('origin/'); + if (aIsRemote && !bIsRemote) return 1; + if (!aIsRemote && bIsRemote) return -1; + return a.localeCompare(b); + }); } catch { return []; } diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 00fd8429..493f9c46 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -1,18 +1,21 @@ import { ipcMain, dialog, app, shell } from 'electron'; -import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs'; +import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs'; import { execFileSync } from 'node:child_process'; import path from 'path'; import { is } from '@electron-toolkit/utils'; import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants'; import type { AppSettings, - IPCResult + IPCResult, + SourceEnvConfig, + SourceEnvCheckResult } from '../../shared/types'; import { AgentManager } from '../agent'; import type { BrowserWindow } from 'electron'; import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform } from '../cli-tool-manager'; +import { parseEnvFile } from './utils'; const settingsPath = getSettingsPath(); @@ -33,13 +36,16 @@ const detectAutoBuildSourcePath = (): string | null => { ); } else { // Production mode paths (packaged app) - // On Windows/Linux/macOS, the app might be installed anywhere - // We check common locations relative to the app bundle + // The backend is bundled as extraResources/backend + // On all platforms, it should be at process.resourcesPath/backend + possiblePaths.push( + path.resolve(process.resourcesPath, 'backend') // Primary: extraResources/backend + ); + // Fallback paths for different app structures const appPath = app.getAppPath(); possiblePaths.push( - path.resolve(appPath, '..', 'backend'), // Sibling to app - path.resolve(appPath, '..', '..', 'backend'), // Up 2 from app - path.resolve(process.resourcesPath, '..', 'backend') // Relative to resources + path.resolve(appPath, '..', 'backend'), // Sibling to asar + path.resolve(appPath, '..', '..', 'Resources', 'backend') // macOS bundle structure ); } @@ -506,4 +512,238 @@ export function registerSettingsHandlers( } } ); + + // ============================================ + // Auto-Build Source Environment Operations + // ============================================ + + /** + * Helper to get source .env path from settings + * + * In production mode, the .env file is NOT bundled (excluded in electron-builder config). + * We store the source .env in app userData directory instead, which is writable. + * The sourcePath points to the bundled backend for reference, but envPath is in userData. + */ + const getSourceEnvPath = (): { + sourcePath: string | null; + envPath: string | null; + isProduction: boolean; + } => { + const savedSettings = readSettingsFile(); + const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + + // Get autoBuildPath from settings or try to auto-detect + let sourcePath: string | null = settings.autoBuildPath || null; + if (!sourcePath) { + sourcePath = detectAutoBuildSourcePath(); + } + + if (!sourcePath) { + return { sourcePath: null, envPath: null, isProduction: !is.dev }; + } + + // In production, use userData directory for .env since resources may be read-only + // In development, use the actual source path + let envPath: string; + if (is.dev) { + envPath = path.join(sourcePath, '.env'); + } else { + // Production: store .env in userData/backend/.env + const userDataBackendDir = path.join(app.getPath('userData'), 'backend'); + if (!existsSync(userDataBackendDir)) { + mkdirSync(userDataBackendDir, { recursive: true }); + } + envPath = path.join(userDataBackendDir, '.env'); + } + + return { + sourcePath, + envPath, + isProduction: !is.dev + }; + }; + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET, + async (): Promise> => { + try { + const { sourcePath, envPath } = getSourceEnvPath(); + + // Load global settings to check for global token fallback + const savedSettings = readSettingsFile(); + const globalSettings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + + if (!sourcePath) { + // Even without source path, check global token + const globalToken = globalSettings.globalClaudeOAuthToken; + return { + success: true, + data: { + hasClaudeToken: !!globalToken && globalToken.length > 0, + claudeOAuthToken: globalToken, + envExists: false + } + }; + } + + const envExists = envPath ? existsSync(envPath) : false; + let hasClaudeToken = false; + let claudeOAuthToken: string | undefined; + + // First, check source .env file + if (envExists && envPath) { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + claudeOAuthToken = vars['CLAUDE_CODE_OAUTH_TOKEN']; + hasClaudeToken = !!claudeOAuthToken && claudeOAuthToken.length > 0; + } + + // Fallback to global settings if no token in source .env + if (!hasClaudeToken && globalSettings.globalClaudeOAuthToken) { + claudeOAuthToken = globalSettings.globalClaudeOAuthToken; + hasClaudeToken = true; + } + + return { + success: true, + data: { + hasClaudeToken, + claudeOAuthToken, + sourcePath, + envExists + } + }; + } catch (error) { + // Log the error for debugging in production + console.error('[AUTOBUILD_SOURCE_ENV_GET] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get source env' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, + async (_, config: { claudeOAuthToken?: string }): Promise => { + try { + const { sourcePath, envPath } = getSourceEnvPath(); + + if (!sourcePath || !envPath) { + return { + success: false, + error: 'Auto-build source path not configured. Please set it in Settings.' + }; + } + + // Read existing content or start fresh (avoiding TOCTOU race condition) + let existingVars: Record = {}; + try { + const content = readFileSync(envPath, 'utf-8'); + existingVars = parseEnvFile(content); + } catch (_readError) { + // File doesn't exist or can't be read - start with empty vars + // This is expected for first-time setup + } + + // Update with new values + if (config.claudeOAuthToken !== undefined) { + existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken; + } + + // Generate content + const lines: string[] = [ + '# Auto Claude Framework Environment Variables', + '# Managed by Auto Claude UI', + '', + '# Claude Code OAuth Token (REQUIRED)', + `CLAUDE_CODE_OAUTH_TOKEN=${existingVars['CLAUDE_CODE_OAUTH_TOKEN'] || ''}`, + '' + ]; + + // Preserve other existing variables + for (const [key, value] of Object.entries(existingVars)) { + if (key !== 'CLAUDE_CODE_OAUTH_TOKEN') { + lines.push(`${key}=${value}`); + } + } + + writeFileSync(envPath, lines.join('\n')); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update source env' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN, + async (): Promise> => { + try { + const { sourcePath, envPath, isProduction } = getSourceEnvPath(); + + // Load global settings to check for global token fallback + const savedSettings = readSettingsFile(); + const globalSettings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + + // Check global token first as it's the primary method + const globalToken = globalSettings.globalClaudeOAuthToken; + const hasGlobalToken = !!globalToken && globalToken.length > 0; + + if (!sourcePath) { + // In production, no source path is acceptable if global token exists + if (hasGlobalToken) { + return { + success: true, + data: { + hasToken: true, + sourcePath: isProduction ? app.getPath('userData') : undefined + } + }; + } + return { + success: true, + data: { + hasToken: false, + error: isProduction + ? 'Please configure Claude OAuth token in Settings > API Configuration' + : 'Auto-build source path not configured' + } + }; + } + + // Check source .env file + let hasEnvToken = false; + if (envPath && existsSync(envPath)) { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + const token = vars['CLAUDE_CODE_OAUTH_TOKEN']; + hasEnvToken = !!token && token.length > 0; + } + + // Token exists if either source .env has it OR global settings has it + const hasToken = hasEnvToken || hasGlobalToken; + + return { + success: true, + data: { + hasToken, + sourcePath + } + }; + } catch (error) { + // Log the error for debugging in production + console.error('[AUTOBUILD_SOURCE_ENV_CHECK_TOKEN] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check source token' + }; + } + } + ); } diff --git a/apps/frontend/src/main/ipc-handlers/shared/label-utils.ts b/apps/frontend/src/main/ipc-handlers/shared/label-utils.ts new file mode 100644 index 00000000..d51ee6fb --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/shared/label-utils.ts @@ -0,0 +1,34 @@ +/** + * Shared label matching utilities + * Used by both GitHub and GitLab spec-utils for category detection + */ + +/** + * Escape special regex characters in a string. + * This ensures that terms like "c++" or "c#" are matched literally. + * + * @param str - The string to escape + * @returns The escaped string safe for use in a RegExp + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Check if a label contains a whole-word match for a term. + * Uses word boundaries to prevent false positives (e.g., 'acid' matching 'ci'). + * + * The term is escaped to handle regex metacharacters safely, so terms like + * "c++" or "c#" are matched literally rather than being interpreted as regex. + * + * @param label - The label to check (already lowercased) + * @param term - The term to search for (will be escaped for regex safety) + * @returns true if the label contains the term as a whole word + */ +export function labelMatchesWholeWord(label: string, term: string): boolean { + // Escape regex metacharacters in the term to match literally + const escapedTerm = escapeRegExp(term); + // Use word boundary regex to match whole words only + const regex = new RegExp(`\\b${escapedTerm}\\b`); + return regex.test(label); +} diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 9c325672..745d6865 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron'; import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types'; import path from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs'; import { spawnSync } from 'child_process'; import { AgentManager } from '../../agent'; import { fileWatcher } from '../../file-watcher'; @@ -12,7 +12,6 @@ import { getClaudeProfileManager } from '../../claude-profile-manager'; import { getPlanPath, persistPlanStatus, - persistPlanStatusSync, createPlanIfNotExists } from './plan-file-utils'; import { findTaskWorktree } from '../../worktree-paths'; @@ -672,17 +671,35 @@ export function registerTaskExecutionHandlers( return { success: false, error: 'Task not found' }; } - // Get the spec directory - const autoBuildDir = project.autoBuildPath || '.auto-claude'; - const specDir = path.join( + // Get the spec directory - use task.specsPath if available (handles worktree vs main) + // This is critical: task might exist in worktree, and getTasks() prefers worktree version. + // If we write to main project but task is in worktree, the worktree's old status takes precedence on refresh. + const specDir = task.specsPath || path.join( project.path, - autoBuildDir, - 'specs', + getSpecsDir(project.autoBuildPath), task.specId ); // Update implementation_plan.json const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + console.log(`[Recovery] Writing to plan file at: ${planPath} (task location: ${task.location || 'main'})`); + + // Also update the OTHER location if task exists in both main and worktree + // This ensures consistency regardless of which version getTasks() prefers + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const mainSpecDir = path.join(project.path, specsBaseDir, task.specId); + const worktreePath = findTaskWorktree(project.path, task.specId); + const worktreeSpecDir = worktreePath ? path.join(worktreePath, specsBaseDir, task.specId) : null; + + // Collect all plan file paths that need updating + const planPathsToUpdate: string[] = [planPath]; + if (mainSpecDir !== specDir && existsSync(path.join(mainSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN))) { + planPathsToUpdate.push(path.join(mainSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN)); + } + if (worktreeSpecDir && worktreeSpecDir !== specDir && existsSync(path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN))) { + planPathsToUpdate.push(path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN)); + } + console.log(`[Recovery] Will update ${planPathsToUpdate.length} plan file(s):`, planPathsToUpdate); try { // Read the plan to analyze subtask progress @@ -744,14 +761,25 @@ export function registerTaskExecutionHandlers( // Just update status in plan file (project store reads from file, no separate update needed) plan.status = 'human_review'; plan.planStatus = 'review'; - try { - // Use atomic write to prevent TOCTOU race conditions - atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2)); - } catch (writeError) { - console.error('[Recovery] Failed to write plan file:', writeError); + + // Write to ALL plan file locations to ensure consistency + const planContent = JSON.stringify(plan, null, 2); + let writeSucceededForComplete = false; + for (const pathToUpdate of planPathsToUpdate) { + try { + atomicWriteFileSync(pathToUpdate, planContent); + console.log(`[Recovery] Successfully wrote to: ${pathToUpdate}`); + writeSucceededForComplete = true; + } catch (writeError) { + console.error(`[Recovery] Failed to write plan file at ${pathToUpdate}:`, writeError); + // Continue trying other paths + } + } + + if (!writeSucceededForComplete) { return { success: false, - error: 'Failed to write plan file' + error: 'Failed to write plan file during recovery (all locations failed)' }; } @@ -798,11 +826,19 @@ export function registerTaskExecutionHandlers( } } - try { - // Use atomic write to prevent TOCTOU race conditions - atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2)); - } catch (writeError) { - console.error('[Recovery] Failed to write plan file:', writeError); + // Write to ALL plan file locations to ensure consistency + const planContent = JSON.stringify(plan, null, 2); + let writeSucceeded = false; + for (const pathToUpdate of planPathsToUpdate) { + try { + atomicWriteFileSync(pathToUpdate, planContent); + console.log(`[Recovery] Successfully wrote to: ${pathToUpdate}`); + writeSucceeded = true; + } catch (writeError) { + console.error(`[Recovery] Failed to write plan file at ${pathToUpdate}:`, writeError); + } + } + if (!writeSucceeded) { return { success: false, error: 'Failed to write plan file during recovery' @@ -854,17 +890,20 @@ export function registerTaskExecutionHandlers( // Set status to in_progress for the restart newStatus = 'in_progress'; - // Update plan status for restart + // Update plan status for restart - write to ALL locations if (plan) { plan.status = 'in_progress'; plan.planStatus = 'in_progress'; - try { - // Use atomic write to prevent TOCTOU race conditions - atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2)); - } catch (writeError) { - console.error('[Recovery] Failed to write plan file for restart:', writeError); - // Continue with restart attempt even if file write fails - // The plan status will be updated by the agent when it starts + const restartPlanContent = JSON.stringify(plan, null, 2); + for (const pathToUpdate of planPathsToUpdate) { + try { + atomicWriteFileSync(pathToUpdate, restartPlanContent); + console.log(`[Recovery] Wrote restart status to: ${pathToUpdate}`); + } catch (writeError) { + console.error(`[Recovery] Failed to write plan file for restart at ${pathToUpdate}:`, writeError); + // Continue with restart attempt even if file write fails + // The plan status will be updated by the agent when it starts + } } } diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 7a9cd8e7..c00ee1f9 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -4,6 +4,7 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre import path from 'path'; import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; +import { minimatch } from 'minimatch'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; import { getEffectiveSourcePath } from '../../updater/path-resolver'; @@ -59,6 +60,145 @@ function getUtilitySettings(): { model: string; modelId: string; thinkingLevel: const execAsync = promisify(exec); const execFileAsync = promisify(execFile); +/** + * Check if a repository is misconfigured as bare but has source files. + * If so, automatically fix the configuration by unsetting core.bare. + * + * This can happen when git worktree operations incorrectly set bare=true, + * or when users manually misconfigure the repository. + * + * @param projectPath - Path to check and potentially fix + * @returns true if fixed, false if no fix needed or not fixable + */ +function fixMisconfiguredBareRepo(projectPath: string): boolean { + try { + // Check if bare=true is set + const bareConfig = execFileSync( + getToolPath('git'), + ['config', '--get', 'core.bare'], + { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ).trim().toLowerCase(); + + if (bareConfig !== 'true') { + return false; // Not marked as bare, nothing to fix + } + + // Check if there are source files (indicating misconfiguration) + // A truly bare repo would only have git internals, not source code + // This covers multiple ecosystems: JS/TS, Python, Rust, Go, Java, C#, etc. + // + // Markers are separated into exact matches and glob patterns for efficiency. + // Exact matches use existsSync() directly, while glob patterns use minimatch + // against a cached directory listing. + const EXACT_MARKERS = [ + // JavaScript/TypeScript ecosystem + 'package.json', 'apps', 'src', + // Python ecosystem + 'pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile', + // Rust ecosystem + 'Cargo.toml', + // Go ecosystem + 'go.mod', 'go.sum', 'cmd', 'main.go', + // Java/JVM ecosystem + 'pom.xml', 'build.gradle', 'build.gradle.kts', + // Ruby ecosystem + 'Gemfile', 'Rakefile', + // PHP ecosystem + 'composer.json', + // General project markers + 'Makefile', 'CMakeLists.txt', 'README.md', 'LICENSE' + ]; + + const GLOB_MARKERS = [ + // .NET/C# ecosystem - patterns that need glob matching + '*.csproj', '*.sln', '*.fsproj' + ]; + + // Check exact matches first (fast path) + const hasExactMatch = EXACT_MARKERS.some(marker => + existsSync(path.join(projectPath, marker)) + ); + + if (hasExactMatch) { + // Found a project marker, proceed to fix + } else { + // Check glob patterns - read directory once and cache for all patterns + let directoryFiles: string[] | null = null; + const MAX_FILES_TO_CHECK = 500; // Limit to avoid reading huge directories + + const hasGlobMatch = GLOB_MARKERS.some(pattern => { + // Validate pattern - only support simple glob patterns for security + if (pattern.includes('..') || pattern.includes('/')) { + console.warn(`[GIT] Unsupported glob pattern ignored: ${pattern}`); + return false; + } + + // Lazy-load directory listing, cached across patterns + if (directoryFiles === null) { + try { + const allFiles = readdirSync(projectPath); + // Limit to first N entries to avoid performance issues + directoryFiles = allFiles.slice(0, MAX_FILES_TO_CHECK); + if (allFiles.length > MAX_FILES_TO_CHECK) { + console.warn(`[GIT] Directory has ${allFiles.length} entries, checking only first ${MAX_FILES_TO_CHECK}`); + } + } catch (error) { + // Log the error for debugging instead of silently swallowing + console.warn(`[GIT] Failed to read directory ${projectPath}:`, error instanceof Error ? error.message : String(error)); + directoryFiles = []; + } + } + + // Use minimatch for proper glob pattern matching + return directoryFiles.some(file => minimatch(file, pattern, { nocase: true })); + }); + + if (!hasGlobMatch) { + return false; // Legitimately bare repo + } + } + + // Fix the misconfiguration + console.warn('[GIT] Detected misconfigured bare repository with source files. Auto-fixing by unsetting core.bare...'); + execFileSync( + getToolPath('git'), + ['config', '--unset', 'core.bare'], + { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + console.warn('[GIT] Fixed: core.bare has been unset. Git operations should now work correctly.'); + return true; + } catch { + return false; + } +} + +/** + * Check if a path is a valid git working tree (not a bare repository). + * Returns true if the path is inside a git repository with a working tree. + * + * NOTE: This is a pure check with no side-effects. If you need to fix + * misconfigured bare repos before an operation, call fixMisconfiguredBareRepo() + * explicitly before calling this function. + * + * @param projectPath - Path to check + * @returns true if it's a valid working tree, false if bare or not a git repo + */ +function isGitWorkTree(projectPath: string): boolean { + try { + // Use git rev-parse --is-inside-work-tree which returns "true" for working trees + // and fails for bare repos or non-git directories + const result = execFileSync( + getToolPath('git'), + ['rev-parse', '--is-inside-work-tree'], + { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + return result.trim() === 'true'; + } catch { + // Not a working tree (could be bare repo or not a git repo at all) + return false; + } +} + /** * IDE and Terminal detection and launching utilities */ @@ -1406,6 +1546,12 @@ export function registerWorktreeHandlers( debug('Found task:', task.specId, 'project:', project.path); + // Auto-fix any misconfigured bare repo before merge operation + // This prevents issues where git operations fail due to incorrect bare=true config + if (fixMisconfiguredBareRepo(project.path)) { + debug('Fixed misconfigured bare repository at:', project.path); + } + // Use run.py --merge to handle the merge const sourcePath = getEffectiveSourcePath(); if (!sourcePath) { @@ -1449,14 +1595,18 @@ export function registerWorktreeHandlers( } } - // Get git status before merge - try { - const gitStatusBefore = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); - debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)'); - const gitBranch = execFileSync(getToolPath('git'), ['branch', '--show-current'], { cwd: project.path, encoding: 'utf-8' }).trim(); - debug('Current branch:', gitBranch); - } catch (e) { - debug('Failed to get git status before:', e); + // Get git status before merge (only if project is a working tree, not a bare repo) + if (isGitWorkTree(project.path)) { + try { + const gitStatusBefore = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); + debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)'); + const gitBranch = execFileSync(getToolPath('git'), ['branch', '--show-current'], { cwd: project.path, encoding: 'utf-8' }).trim(); + debug('Current branch:', gitBranch); + } catch (e) { + debug('Failed to get git status before:', e); + } + } else { + debug('Project is a bare repository - skipping pre-merge git status check'); } const args = [ @@ -1600,14 +1750,18 @@ export function registerWorktreeHandlers( debug('Full stdout:', stdout); debug('Full stderr:', stderr); - // Get git status after merge - try { - const gitStatusAfter = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); - debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)'); - const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); - debug('Staged changes:\n', gitDiffStaged || '(none)'); - } catch (e) { - debug('Failed to get git status after:', e); + // Get git status after merge (only if project is a working tree, not a bare repo) + if (isGitWorkTree(project.path)) { + try { + const gitStatusAfter = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); + debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)'); + const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); + debug('Staged changes:\n', gitDiffStaged || '(none)'); + } catch (e) { + debug('Failed to get git status after:', e); + } + } else { + debug('Project is a bare repository - skipping git status check (this is normal for worktree-based projects)'); } if (code === 0) { @@ -1619,33 +1773,39 @@ export function registerWorktreeHandlers( let mergeAlreadyCommitted = false; if (isStageOnly) { - try { - const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); - hasActualStagedChanges = gitDiffStaged.trim().length > 0; - debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges); + // Only check staged changes if project is a working tree (not bare repo) + if (isGitWorkTree(project.path)) { + try { + const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); + hasActualStagedChanges = gitDiffStaged.trim().length > 0; + debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges); - if (!hasActualStagedChanges) { - // Check if worktree branch was already merged (merge commit exists) - const specBranch = `auto-claude/${task.specId}`; - 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 - 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 - mergeAlreadyCommitted = true; - debug('Merge already committed check:', mergeAlreadyCommitted); - } catch { - // Exit code 1 means not merged, or branch may not exist - mergeAlreadyCommitted = false; - debug('Could not check merge status, assuming not merged'); + if (!hasActualStagedChanges) { + // Check if worktree branch was already merged (merge commit exists) + const specBranch = `auto-claude/${task.specId}`; + 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 + execFileSync( + getToolPath('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 + mergeAlreadyCommitted = true; + debug('Merge already committed check:', mergeAlreadyCommitted); + } catch { + // Exit code 1 means not merged, or branch may not exist + mergeAlreadyCommitted = false; + debug('Could not check merge status, assuming not merged'); + } } + } catch (e) { + debug('Failed to verify staged changes:', e); } - } catch (e) { - debug('Failed to verify staged changes:', e); + } else { + // For bare repos, skip staging verification - merge happens in worktree + debug('Project is a bare repository - skipping staged changes verification'); } } @@ -1857,8 +2017,17 @@ export function registerWorktreeHandlers( } }); } else { - // Check if there were conflicts - const hasConflicts = stdout.includes('conflict') || stderr.includes('conflict'); + // Check if there were actual merge conflicts + // More specific patterns to avoid false positives from debug output like "files_with_conflicts: 0" + const conflictPatterns = [ + /CONFLICT \(/i, // Git merge conflict marker + /merge conflict/i, // Explicit merge conflict message + /\bconflict detected\b/i, // Our own conflict detection message + /\bconflicts? found\b/i, // "conflicts found" or "conflict found" + /Automatic merge failed/i, // Git's automatic merge failure message + ]; + const combinedOutput = stdout + stderr; + const hasConflicts = conflictPatterns.some(pattern => pattern.test(combinedOutput)); debug('Merge failed. hasConflicts:', hasConflicts); resolve({ @@ -1935,27 +2104,31 @@ export function registerWorktreeHandlers( } console.warn('[IPC] Found task:', task.specId, 'project:', project.name); - // Check for uncommitted changes in the main project + // Check for uncommitted changes in the main project (only if not a bare repo) let hasUncommittedChanges = false; let uncommittedFiles: string[] = []; - try { - const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], { - cwd: project.path, - encoding: 'utf-8' - }); + if (isGitWorkTree(project.path)) { + try { + const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], { + cwd: project.path, + encoding: 'utf-8' + }); - if (gitStatus && gitStatus.trim()) { - // Parse the status output to get file names - // Format: XY filename (where X and Y are status chars, then space, then filename) - uncommittedFiles = gitStatus - .split('\n') - .filter(line => line.trim()) - .map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace + if (gitStatus && gitStatus.trim()) { + // Parse the status output to get file names + // Format: XY filename (where X and Y are status chars, then space, then filename) + uncommittedFiles = gitStatus + .split('\n') + .filter(line => line.trim()) + .map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace - hasUncommittedChanges = uncommittedFiles.length > 0; + hasUncommittedChanges = uncommittedFiles.length > 0; + } + } catch (e) { + console.error('[IPC] Failed to check git status:', e); } - } catch (e) { - console.error('[IPC] Failed to check git status:', e); + } else { + console.warn('[IPC] Project is a bare repository - skipping uncommitted changes check'); } const sourcePath = getEffectiveSourcePath(); diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index b76d1363..e870fef4 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -76,6 +76,22 @@ export function registerTerminalHandlers( } ); + // Set terminal title (user renamed terminal in renderer) + ipcMain.on( + IPC_CHANNELS.TERMINAL_SET_TITLE, + (_, id: string, title: string) => { + terminalManager.setTitle(id, title); + } + ); + + // Set terminal worktree config (user changed worktree association in renderer) + ipcMain.on( + IPC_CHANNELS.TERMINAL_SET_WORKTREE_CONFIG, + (_, id: string, config: import('../../shared/types').TerminalWorktreeConfig | undefined) => { + terminalManager.setWorktreeConfig(id, config); + } + ); + // Claude profile management (multi-account support) ipcMain.handle( IPC_CHANNELS.CLAUDE_PROFILES_GET, diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index 9c1f8586..6ebd86f3 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -159,28 +159,41 @@ async function createTerminalWorktree( const baseBranch = customBaseBranch || getDefaultBranch(projectPath); debugLog('[TerminalWorktree] Using base branch:', baseBranch, customBaseBranch ? '(custom)' : '(default)'); + // Check if baseBranch is already a remote ref (e.g., "origin/feature-x") + const isRemoteRef = baseBranch.startsWith('origin/'); + const remoteBranchName = isRemoteRef ? baseBranch.replace('origin/', '') : baseBranch; + + // Fetch the branch from remote try { - execFileSync('git', ['fetch', 'origin', baseBranch], { + execFileSync('git', ['fetch', 'origin', remoteBranchName], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); - debugLog('[TerminalWorktree] Fetched latest from origin/' + baseBranch); + debugLog('[TerminalWorktree] Fetched latest from origin/' + remoteBranchName); } catch { debugLog('[TerminalWorktree] Could not fetch from remote, continuing with local branch'); } + // Determine the base ref to use for worktree creation let baseRef = baseBranch; - try { - execFileSync('git', ['rev-parse', '--verify', `origin/${baseBranch}`], { - cwd: projectPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - baseRef = `origin/${baseBranch}`; - debugLog('[TerminalWorktree] Using remote ref:', baseRef); - } catch { - debugLog('[TerminalWorktree] Remote ref not found, using local branch:', baseBranch); + if (isRemoteRef) { + // Already a remote ref, use as-is + baseRef = baseBranch; + debugLog('[TerminalWorktree] Using remote ref directly:', baseRef); + } else { + // Check if remote version exists and use it for latest code + try { + execFileSync('git', ['rev-parse', '--verify', `origin/${baseBranch}`], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + baseRef = `origin/${baseBranch}`; + debugLog('[TerminalWorktree] Using remote ref:', baseRef); + } catch { + debugLog('[TerminalWorktree] Remote ref not found, using local branch:', baseBranch); + } } if (createGitBranch) { diff --git a/apps/frontend/src/main/memory-env-builder.ts b/apps/frontend/src/main/memory-env-builder.ts index 804c9526..6382757d 100644 --- a/apps/frontend/src/main/memory-env-builder.ts +++ b/apps/frontend/src/main/memory-env-builder.ts @@ -8,6 +8,7 @@ */ import type { AppSettings } from '../shared/types/settings'; +import { getMemoriesDir } from './config-paths'; /** * Build environment variables for memory/Graphiti configuration from app settings. @@ -26,6 +27,10 @@ export function buildMemoryEnvVars(settings: AppSettings): Record apps/backend path.resolve(__dirname, '..', '..', '..', 'backend', 'query_memory.py'), path.resolve(app.getAppPath(), '..', 'backend', 'query_memory.py'), @@ -112,6 +114,68 @@ function getQueryScriptPath(): string | null { return null; } +/** + * Get the backend venv Python path. + * The backend venv has real_ladybug installed (required for memory operations). + * Falls back to getConfiguredPythonPath() for packaged apps. + */ +function getBackendPythonPath(): string { + // For packaged apps, use the bundled Python which has real_ladybug in site-packages + if (app.isPackaged) { + const fallbackPython = getConfiguredPythonPath(); + console.log(`[MemoryService] Using bundled Python for packaged app: ${fallbackPython}`); + return fallbackPython; + } + + // Development mode: Find the backend venv which has real_ladybug installed + const possibleBackendPaths = [ + path.resolve(__dirname, '..', '..', '..', 'backend'), + path.resolve(app.getAppPath(), '..', 'backend'), + path.resolve(process.cwd(), 'apps', 'backend') + ]; + + for (const backendPath of possibleBackendPaths) { + // Check for backend venv Python (has real_ladybug installed) + const venvPython = process.platform === 'win32' + ? path.join(backendPath, '.venv', 'Scripts', 'python.exe') + : path.join(backendPath, '.venv', 'bin', 'python'); + + if (fs.existsSync(venvPython)) { + console.log(`[MemoryService] Using backend venv Python: ${venvPython}`); + return venvPython; + } + } + + // Fall back to configured Python path + const fallbackPython = getConfiguredPythonPath(); + console.log(`[MemoryService] Backend venv not found, falling back to: ${fallbackPython}`); + return fallbackPython; +} + +/** + * Get the Python environment variables for memory queries. + * This ensures real_ladybug can be found in both dev and packaged modes. + */ +function getMemoryPythonEnv(): Record { + // Start with the standard Python environment from the manager + const baseEnv = pythonEnvManager.getPythonEnv(); + + // For packaged apps, ensure PYTHONPATH includes bundled site-packages + // even if the manager hasn't been fully initialized + if (app.isPackaged) { + const bundledSitePackages = path.join(process.resourcesPath, 'python-site-packages'); + if (fs.existsSync(bundledSitePackages)) { + // Merge paths: bundled site-packages takes precedence + const existingPath = baseEnv.PYTHONPATH || ''; + baseEnv.PYTHONPATH = existingPath + ? `${bundledSitePackages}${path.delimiter}${existingPath}` + : bundledSitePackages; + } + } + + return baseEnv; +} + /** * Execute a Python memory query command */ @@ -120,7 +184,10 @@ async function executeQuery( args: string[], timeout: number = 10000 ): Promise { - const pythonCmd = getConfiguredPythonPath(); + // Use getBackendPythonPath() to find the correct Python: + // - In dev mode: uses backend venv with real_ladybug installed + // - In packaged app: falls back to bundled Python + const pythonCmd = getBackendPythonPath(); const scriptPath = getQueryScriptPath(); if (!scriptPath) { @@ -131,11 +198,16 @@ async function executeQuery( return new Promise((resolve) => { const fullArgs = [...baseArgs, scriptPath, command, ...args]; + + // Get Python environment (includes PYTHONPATH for bundled/venv packages) + // This is critical for finding real_ladybug (LadybugDB) + const pythonEnv = getMemoryPythonEnv(); + const proc = spawn(pythonExe, fullArgs, { stdio: ['ignore', 'pipe', 'pipe'], timeout, - // Use sanitized Python environment to prevent PYTHONHOME contamination - env: pythonEnvManager.getPythonEnv(), + // Use pythonEnv which combines sanitized env + site-packages for real_ladybug + env: pythonEnv, }); let stdout = ''; @@ -150,19 +222,29 @@ async function executeQuery( }); proc.on('close', (code) => { - if (code === 0 && stdout) { + // The Python script outputs JSON to stdout (even for errors) + // Always try to parse stdout first to get the actual error message + if (stdout) { try { const result = JSON.parse(stdout); resolve(result); + return; } catch { + // JSON parsing failed + if (code !== 0) { + const errorMsg = stderr || stdout || `Process exited with code ${code}`; + console.error('[MemoryService] Python error:', errorMsg); + resolve({ success: false, error: errorMsg }); + return; + } resolve({ success: false, error: `Invalid JSON response: ${stdout}` }); + return; } - } else { - resolve({ - success: false, - error: stderr || `Process exited with code ${code}`, - }); } + // No stdout - use stderr or generic error + const errorMsg = stderr || `Process exited with code ${code}`; + console.error('[MemoryService] Python error (no stdout):', errorMsg); + resolve({ success: false, error: errorMsg }); }); proc.on('error', (err) => { @@ -185,7 +267,10 @@ async function executeSemanticQuery( embedderConfig: EmbedderConfig, timeout: number = 30000 // Longer timeout for embedding operations ): Promise { - const pythonCmd = getConfiguredPythonPath(); + // Use getBackendPythonPath() to find the correct Python: + // - In dev mode: uses backend venv with real_ladybug installed + // - In packaged app: falls back to bundled Python + const pythonCmd = getBackendPythonPath(); const scriptPath = getQueryScriptPath(); if (!scriptPath) { @@ -194,9 +279,13 @@ async function executeSemanticQuery( const [pythonExe, baseArgs] = parsePythonCommand(pythonCmd); + // Get Python environment (includes PYTHONPATH for bundled/venv packages) + // This is critical for finding real_ladybug (LadybugDB) + const pythonEnv = getMemoryPythonEnv(); + // Build environment with embedder configuration - // Start with sanitized Python env to prevent PYTHONHOME contamination - const env: Record = { ...pythonEnvManager.getPythonEnv() }; + // Use pythonEnv which combines sanitized env + site-packages for real_ladybug + const env: Record = { ...pythonEnv }; // Set the embedder provider env.GRAPHITI_EMBEDDER_PROVIDER = embedderConfig.provider; @@ -275,19 +364,26 @@ async function executeSemanticQuery( }); proc.on('close', (code) => { - if (code === 0 && stdout) { + // The Python script outputs JSON to stdout (even for errors) + if (stdout) { try { const result = JSON.parse(stdout); resolve(result); + return; } catch { + if (code !== 0) { + const errorMsg = stderr || stdout || `Process exited with code ${code}`; + console.error('[MemoryService] Semantic search error:', errorMsg); + resolve({ success: false, error: errorMsg }); + return; + } resolve({ success: false, error: `Invalid JSON response: ${stdout}` }); + return; } - } else { - resolve({ - success: false, - error: stderr || `Process exited with code ${code}`, - }); } + const errorMsg = stderr || `Process exited with code ${code}`; + console.error('[MemoryService] Semantic search error (no stdout):', errorMsg); + resolve({ success: false, error: errorMsg }); }); proc.on('error', (err) => { @@ -529,6 +625,50 @@ export class MemoryService { }; } + /** + * Add an episode to the memory database + * + * This allows the Electron app to save memories (like PR review insights) + * directly to LadybugDB without going through the full Graphiti system. + * + * @param name Episode name/title + * @param content Episode content (will be JSON stringified if object) + * @param episodeType Type of episode (session_insight, pattern, gotcha, task_outcome, pr_review) + * @param groupId Optional group ID for namespacing + * @returns Promise with the created episode info + */ + async addEpisode( + name: string, + content: string | object, + episodeType: string = 'session_insight', + groupId?: string + ): Promise<{ success: boolean; id?: string; error?: string }> { + // Stringify content if it's an object + const contentStr = typeof content === 'object' ? JSON.stringify(content) : content; + + const args = [ + this.config.dbPath, + this.config.database, + '--name', name, + '--content', contentStr, + '--type', episodeType, + ]; + + if (groupId) { + args.push('--group-id', groupId); + } + + const result = await executeQuery('add-episode', args); + + if (!result.success) { + console.error('Failed to add episode:', result.error); + return { success: false, error: result.error }; + } + + const data = result.data as { id: string; name: string; type: string; timestamp: string }; + return { success: true, id: data.id }; + } + /** * Close the database connection (no-op for subprocess model) */ diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index 667eadb6..2c38d3eb 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -563,11 +563,16 @@ export class ProjectStore { // planStatus: "review" indicates spec creation is complete and awaiting user approval const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review'; + // Determine if there is remaining work to do + // True if: no subtasks exist yet (planning in progress) OR some subtasks are incomplete + // This prevents 'in_progress' from overriding 'human_review' when all work is done + const hasRemainingWork = allSubtasks.length === 0 || allSubtasks.some((s) => s.status !== 'completed'); + const isStoredStatusValid = (storedStatus === calculatedStatus) || // Matches calculated (storedStatus === 'human_review' && (calculatedStatus === 'ai_review' || calculatedStatus === 'in_progress')) || // Human review is more advanced than ai_review or in_progress (fixes status loop bug) (storedStatus === 'human_review' && isPlanReviewStage) || // Plan review stage (awaiting spec approval) - (isActiveProcessStatus && storedStatus === 'in_progress'); // Planning/coding phases should show as in_progress + (isActiveProcessStatus && storedStatus === 'in_progress' && hasRemainingWork); // Planning/coding phases should show as in_progress ONLY when there's remaining work if (isStoredStatusValid) { // Preserve reviewReason for human_review status diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index b3637756..e108173a 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -1,6 +1,7 @@ import { app } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import type { TerminalWorktreeConfig } from '../shared/types'; /** * Persisted terminal session data @@ -15,6 +16,8 @@ export interface TerminalSession { outputBuffer: string; // Last 100KB of output for replay createdAt: string; // ISO timestamp lastActiveAt: string; // ISO timestamp + /** Associated worktree configuration (validated on restore) */ + worktreeConfig?: TerminalWorktreeConfig; } /** @@ -203,21 +206,47 @@ export class TerminalSessionStore { this.save(); } + /** + * Validate worktree config - check if the worktree still exists + * Returns undefined if worktree doesn't exist or is invalid + */ + private validateWorktreeConfig(config: TerminalWorktreeConfig | undefined): TerminalWorktreeConfig | undefined { + if (!config) return undefined; + + // Check if the worktree path still exists + if (!existsSync(config.worktreePath)) { + console.warn(`[TerminalSessionStore] Worktree path no longer exists: ${config.worktreePath}, clearing config`); + return undefined; + } + + return config; + } + /** * Get most recent sessions for a project. * First checks today, then looks at the most recent date with sessions. - * This ensures sessions survive app restarts even after midnight. + * When restoring from a previous date, MIGRATES sessions to today to prevent + * duplication issues across days. + * Validates worktree configs - clears them if worktree no longer exists. */ getSessions(projectPath: string): TerminalSession[] { + const today = getDateString(); + // First check today const todaySessions = this.getTodaysSessions(); if (todaySessions[projectPath]?.length > 0) { - return todaySessions[projectPath]; + // Validate worktree configs before returning + return todaySessions[projectPath].map(session => ({ + ...session, + worktreeConfig: this.validateWorktreeConfig(session.worktreeConfig), + })); } // If no sessions today, find the most recent date with sessions for this project const dates = Object.keys(this.data.sessionsByDate) .filter(date => { + // Exclude today since we already checked it + if (date === today) return false; const sessions = this.data.sessionsByDate[date][projectPath]; return sessions && sessions.length > 0; }) @@ -225,8 +254,34 @@ export class TerminalSessionStore { if (dates.length > 0) { const mostRecentDate = dates[0]; - console.warn(`[TerminalSessionStore] No sessions today, using sessions from ${mostRecentDate}`); - return this.data.sessionsByDate[mostRecentDate][projectPath] || []; + console.warn(`[TerminalSessionStore] No sessions today, migrating sessions from ${mostRecentDate} to today`); + const sessions = this.data.sessionsByDate[mostRecentDate][projectPath] || []; + + // MIGRATE: Copy sessions to today's bucket with validated worktree configs + const migratedSessions = sessions.map(session => ({ + ...session, + worktreeConfig: this.validateWorktreeConfig(session.worktreeConfig), + // Update lastActiveAt to now since we're restoring them + lastActiveAt: new Date().toISOString(), + })); + + // Add migrated sessions to today + todaySessions[projectPath] = migratedSessions; + + // Remove sessions from the old date to prevent duplication + delete this.data.sessionsByDate[mostRecentDate][projectPath]; + + // Clean up empty date buckets + if (Object.keys(this.data.sessionsByDate[mostRecentDate]).length === 0) { + delete this.data.sessionsByDate[mostRecentDate]; + } + + // Save the migration + this.save(); + + console.warn(`[TerminalSessionStore] Migrated ${migratedSessions.length} sessions from ${mostRecentDate} to ${today}`); + + return migratedSessions; } return []; @@ -234,11 +289,17 @@ export class TerminalSessionStore { /** * Get sessions for a specific date and project + * Validates worktree configs - clears them if worktree no longer exists. */ getSessionsForDate(date: string, projectPath: string): TerminalSession[] { const dateSessions = this.data.sessionsByDate[date]; if (!dateSessions) return []; - return dateSessions[projectPath] || []; + const sessions = dateSessions[projectPath] || []; + // Validate worktree configs before returning + return sessions.map(session => ({ + ...session, + worktreeConfig: this.validateWorktreeConfig(session.worktreeConfig), + })); } /** diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ae761772..0a72c322 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -263,6 +263,21 @@ export function invokeClaude( const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`; debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); terminal.pty.write(command); + + // Update terminal title and persist session + const title = `Claude (${activeProfile.name})`; + terminal.title = title; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); + } + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + if (projectPath) { + onSessionCapture(terminal.id, projectPath, startTime); + } + debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); return; } else if (activeProfile.configDir) { @@ -274,6 +289,21 @@ export function invokeClaude( const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`; debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); terminal.pty.write(command); + + // Update terminal title and persist session + const title = `Claude (${activeProfile.name})`; + terminal.title = title; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); + } + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + if (projectPath) { + onSessionCapture(terminal.id, projectPath, startTime); + } + debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); return; } else { @@ -293,11 +323,14 @@ export function invokeClaude( profileManager.markProfileUsed(activeProfile.id); } + // Update terminal title in main process and notify renderer + const title = activeProfile && !activeProfile.isDefault + ? `Claude (${activeProfile.name})` + : 'Claude'; + terminal.title = title; + const win = getWindow(); if (win) { - const title = activeProfile && !activeProfile.isDefault - ? `Claude (${activeProfile.name})` - : 'Claude'; win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); } @@ -333,10 +366,17 @@ export function resumeClaude( terminal.pty.write(`${command}\r`); + // Update terminal title in main process and notify renderer + terminal.title = 'Claude'; const win = getWindow(); if (win) { win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); } + + // Persist session with updated title + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } } /** diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index 72458ef2..e955935a 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -79,3 +79,83 @@ export function hasRateLimitMessage(data: string): boolean { export function hasOAuthToken(data: string): boolean { return OAUTH_TOKEN_PATTERN.test(data); } + +/** + * Patterns indicating Claude Code is busy/processing + * These appear when Claude is actively thinking or working + * + * IMPORTANT: These must be universal patterns that work for ALL users, + * not just custom terminal configurations with progress bars. + */ +const CLAUDE_BUSY_PATTERNS = [ + // Universal Claude Code indicators + /^●/m, // Claude's response bullet point (appears when Claude is responding) + /\u25cf/, // Unicode bullet point (●) + + // Tool execution indicators (Claude is running tools) + /^(Read|Write|Edit|Bash|Grep|Glob|Task|WebFetch|WebSearch|TodoWrite)\(/m, + /^\s*\d+\s*[│|]\s*/m, // Line numbers in file output (Claude reading/showing files) + + // Streaming/thinking indicators + /Loading\.\.\./i, + /Thinking\.\.\./i, + /Analyzing\.\.\./i, + /Processing\.\.\./i, + /Working\.\.\./i, + /Searching\.\.\./i, + /Creating\.\.\./i, + /Updating\.\.\./i, + /Running\.\.\./i, + + // Custom progress bar patterns (for users who have them) + /\[Opus\s*\d*\.?\d*\].*\d+%/i, // Opus model progress + /\[Sonnet\s*\d*\.?\d*\].*\d+%/i, // Sonnet model progress + /\[Haiku\s*\d*\.?\d*\].*\d+%/i, // Haiku model progress + /\[Claude\s*\d*\.?\d*\].*\d+%/i, // Generic Claude progress + /░+/, // Progress bar characters + /▓+/, // Progress bar characters + /█+/, // Progress bar characters (filled) +]; + +/** + * Patterns indicating Claude Code is idle/ready for input + * The prompt character at the start of a line indicates Claude is waiting + */ +const CLAUDE_IDLE_PATTERNS = [ + /^>\s*$/m, // Just "> " prompt on its own line + /\n>\s*$/, // "> " at end after newline + /^\s*>\s+$/m, // "> " with possible whitespace +]; + +/** + * Check if output indicates Claude is busy (processing) + */ +export function isClaudeBusyOutput(data: string): boolean { + return CLAUDE_BUSY_PATTERNS.some(pattern => pattern.test(data)); +} + +/** + * Check if output indicates Claude is idle (ready for input) + */ +export function isClaudeIdleOutput(data: string): boolean { + return CLAUDE_IDLE_PATTERNS.some(pattern => pattern.test(data)); +} + +/** + * Determine Claude busy state from output + * Returns: 'busy' | 'idle' | null (no change detected) + */ +export function detectClaudeBusyState(data: string): 'busy' | 'idle' | null { + // Check for busy indicators FIRST - they're more definitive + // Progress bars and "Loading..." mean Claude is definitely working, + // even if there's a ">" prompt visible elsewhere in the output + if (isClaudeBusyOutput(data)) { + return 'busy'; + } + // Only check for idle if no busy indicators found + // The ">" prompt alone at end of output means Claude is waiting for input + if (isClaudeIdleOutput(data)) { + return 'idle'; + } + return null; +} diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 5fe8349c..bd38c07a 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -86,13 +86,21 @@ export function spawnPtyProcess( console.warn('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ')'); + // Create a clean environment without DEBUG to prevent Claude Code from + // enabling debug mode when the Electron app is run in development mode. + // Also remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens + // (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys that may + // be present in the shell environment. Without this, Claude Code would + // show "Claude API" instead of "Claude Max" when ANTHROPIC_API_KEY is set. + const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env; + return pty.spawn(shell, shellArgs, { name: 'xterm-256color', cols, rows, cwd: cwd || os.homedir(), env: { - ...process.env, + ...cleanEnv, ...profileEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', diff --git a/apps/frontend/src/main/terminal/session-handler.ts b/apps/frontend/src/main/terminal/session-handler.ts index 9ac08fe5..f26a8965 100644 --- a/apps/frontend/src/main/terminal/session-handler.ts +++ b/apps/frontend/src/main/terminal/session-handler.ts @@ -106,7 +106,8 @@ export function persistSession(terminal: TerminalProcess): void { claudeSessionId: terminal.claudeSessionId, outputBuffer: terminal.outputBuffer, createdAt: new Date().toISOString(), - lastActiveAt: new Date().toISOString() + lastActiveAt: new Date().toISOString(), + worktreeConfig: terminal.worktreeConfig, }; store.saveSession(session); } diff --git a/apps/frontend/src/main/terminal/terminal-event-handler.ts b/apps/frontend/src/main/terminal/terminal-event-handler.ts index 79a5b073..7f8b061d 100644 --- a/apps/frontend/src/main/terminal/terminal-event-handler.ts +++ b/apps/frontend/src/main/terminal/terminal-event-handler.ts @@ -6,6 +6,7 @@ import * as OutputParser from './output-parser'; import * as ClaudeIntegration from './claude-integration-handler'; import type { TerminalProcess, WindowGetter } from './types'; +import { IPC_CHANNELS } from '../../shared/constants'; /** * Event handler callbacks @@ -14,8 +15,12 @@ export interface EventHandlerCallbacks { onClaudeSessionId: (terminal: TerminalProcess, sessionId: string) => void; onRateLimit: (terminal: TerminalProcess, data: string) => void; onOAuthToken: (terminal: TerminalProcess, data: string) => void; + onClaudeBusyChange: (terminal: TerminalProcess, isBusy: boolean) => void; } +// Track the last known busy state per terminal to avoid duplicate events +const lastBusyState = new Map(); + /** * Handle terminal data output */ @@ -39,6 +44,28 @@ export function handleTerminalData( // Check for OAuth token callbacks.onOAuthToken(terminal, data); + + // Detect Claude busy state changes (only when in Claude mode) + if (terminal.isClaudeMode) { + const busyState = OutputParser.detectClaudeBusyState(data); + if (busyState !== null) { + const isBusy = busyState === 'busy'; + const lastState = lastBusyState.get(terminal.id); + + // Only emit if state actually changed + if (lastState !== isBusy) { + lastBusyState.set(terminal.id, isBusy); + callbacks.onClaudeBusyChange(terminal, isBusy); + } + } + } +} + +/** + * Clear busy state tracking for a terminal (call on terminal destruction) + */ +export function clearBusyState(terminalId: string): void { + lastBusyState.delete(terminalId); } /** @@ -64,6 +91,12 @@ export function createEventCallbacks( }, onOAuthToken: (terminal, data) => { ClaudeIntegration.handleOAuthToken(terminal, data, getWindow); + }, + onClaudeBusyChange: (terminal, isBusy) => { + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy); + } } }; } diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index d0ee85fb..51544722 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -4,6 +4,7 @@ */ import * as os from 'os'; +import { existsSync } from 'fs'; import type { TerminalCreateOptions } from '../../shared/types'; import { IPC_CHANNELS } from '../../shared/constants'; import type { TerminalSession } from '../terminal-session-store'; @@ -22,6 +23,8 @@ import { debugLog, debugError } from '../../shared/utils/debug-logger'; export interface RestoreOptions { resumeClaudeSession: boolean; captureSessionId: (terminalId: string, projectPath: string, startTime: number) => void; + /** Callback triggered when a Claude session needs to be resumed */ + onResumeNeeded?: (terminalId: string, sessionId: string) => void; } /** @@ -111,12 +114,31 @@ export async function restoreTerminal( cols = 80, rows = 24 ): Promise { - debugLog('[TerminalLifecycle] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode); + // Look up the stored session to get the correct isClaudeMode value + // The renderer may pass isClaudeMode: false (by design), but we need the stored value + // to determine whether to auto-resume Claude + const storedSessions = SessionHandler.getSavedSessions(session.projectPath); + const storedSession = storedSessions.find(s => s.id === session.id); + const storedIsClaudeMode = storedSession?.isClaudeMode ?? session.isClaudeMode; + const storedClaudeSessionId = storedSession?.claudeSessionId ?? session.claudeSessionId; + + debugLog('[TerminalLifecycle] Restoring terminal session:', session.id, + 'Passed Claude mode:', session.isClaudeMode, + 'Stored Claude mode:', storedIsClaudeMode, + 'Stored session ID:', storedClaudeSessionId); + + // Validate cwd exists - if the directory was deleted (e.g., worktree removed), + // fall back to project path to prevent shell exit with code 1 + let effectiveCwd = session.cwd; + if (!existsSync(session.cwd)) { + debugLog('[TerminalLifecycle] Session cwd does not exist, falling back to project path:', session.cwd, '->', session.projectPath); + effectiveCwd = session.projectPath || os.homedir(); + } const result = await createTerminal( { id: session.id, - cwd: session.cwd, + cwd: effectiveCwd, cols, rows, projectPath: session.projectPath @@ -135,19 +157,59 @@ export async function restoreTerminal( return { success: false, error: 'Terminal not found after creation' }; } + // Restore title and worktree config from session terminal.title = session.title; + // Only restore worktree config if the worktree directory still exists + // (effectiveCwd matching session.cwd means no fallback was needed) + if (effectiveCwd === session.cwd) { + terminal.worktreeConfig = session.worktreeConfig; + } else { + // Worktree was deleted, clear the config and update terminal's cwd + terminal.worktreeConfig = undefined; + terminal.cwd = effectiveCwd; + debugLog('[TerminalLifecycle] Cleared worktree config for terminal with deleted worktree:', session.id); + } - // Restore Claude mode state without sending resume commands - // The PTY daemon keeps processes alive, so we just need to reconnect to the existing session - if (session.isClaudeMode) { + // Send title change event for all restored terminals so renderer updates + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, session.title); + } + + // Auto-resume Claude if session was in Claude mode with a session ID + // Use storedIsClaudeMode and storedClaudeSessionId which come from the persisted store, + // not the renderer-passed values (renderer always passes isClaudeMode: false) + if (options.resumeClaudeSession && storedIsClaudeMode && storedClaudeSessionId) { terminal.isClaudeMode = true; - terminal.claudeSessionId = session.claudeSessionId; + terminal.claudeSessionId = storedClaudeSessionId; + debugLog('[TerminalLifecycle] Auto-resuming Claude session:', storedClaudeSessionId); - debugLog('[TerminalLifecycle] Restored Claude mode state for session:', session.id, 'sessionId:', session.claudeSessionId); - - const win = getWindow(); + // Notify renderer of the Claude session so it can update its store + // This prevents the renderer from also trying to resume (duplicate command) if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, session.title); + win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, storedClaudeSessionId); + } + + // Persist the restored Claude mode state immediately to avoid data loss + // if app closes before the 30-second periodic save + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + + // Small delay to ensure PTY is ready before sending resume command + if (options.onResumeNeeded) { + setTimeout(() => { + options.onResumeNeeded!(terminal.id, storedClaudeSessionId); + }, 500); + } + } else if (storedClaudeSessionId) { + // Keep session ID for manual resume (no auto-resume if not in Claude mode) + terminal.claudeSessionId = storedClaudeSessionId; + debugLog('[TerminalLifecycle] Preserved Claude session ID for manual resume:', storedClaudeSessionId); + + // Persist the session ID so it's available even if app closes before periodic save + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); } } diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index f2ab44a7..72e7b983 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -80,6 +80,9 @@ export class TerminalManager { this.terminals, this.getWindow ); + }, + onResumeNeeded: (terminalId, sessionId) => { + this.resumeClaude(terminalId, sessionId); } }, cols, @@ -239,6 +242,9 @@ export class TerminalManager { this.terminals, this.getWindow ); + }, + onResumeNeeded: (terminalId, sessionId) => { + this.resumeClaude(terminalId, sessionId); } }, cols, @@ -279,6 +285,20 @@ export class TerminalManager { } } + /** + * Update terminal worktree config + */ + setWorktreeConfig(id: string, config: import('../../shared/types').TerminalWorktreeConfig | undefined): void { + const terminal = this.terminals.get(id); + if (terminal) { + terminal.worktreeConfig = config; + // Persist immediately when worktree config changes + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + } + } + /** * Check if a terminal's PTY process is alive */ diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index 7a361890..f203973f 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -1,5 +1,6 @@ import type * as pty from '@lydell/node-pty'; import type { BrowserWindow } from 'electron'; +import type { TerminalWorktreeConfig } from '../../shared/types'; /** * Terminal process tracking @@ -14,6 +15,8 @@ export interface TerminalProcess { claudeProfileId?: string; outputBuffer: string; title: string; + /** Associated worktree configuration (persisted across restarts) */ + worktreeConfig?: TerminalWorktreeConfig; } /** diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index f27f4883..dddd9683 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -125,6 +125,26 @@ export interface AnalyzePreviewResult { error?: string; } +/** + * Workflow run awaiting approval (for fork PRs) + */ +export interface WorkflowAwaitingApproval { + id: number; + name: string; + html_url: string; + workflow_name: string; +} + +/** + * Workflows awaiting approval result + */ +export interface WorkflowsAwaitingApprovalResult { + awaiting_approval: number; + workflow_runs: WorkflowAwaitingApproval[]; + can_approve: boolean; + error?: string; +} + /** * GitHub Integration API operations */ @@ -234,15 +254,17 @@ export interface GitHubAPI { ) => IpcListenerCleanup; // PR operations - listPRs: (projectId: string) => Promise; + listPRs: (projectId: string, page?: number) => Promise; + getPR: (projectId: string, prNumber: number) => Promise; runPRReview: (projectId: string, prNumber: number) => void; cancelPRReview: (projectId: string, prNumber: number) => Promise; - postPRReview: (projectId: string, prNumber: number, selectedFindingIds?: string[]) => Promise; + postPRReview: (projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }) => Promise; deletePRReview: (projectId: string, prNumber: number) => Promise; postPRComment: (projectId: string, prNumber: number, body: string) => Promise; mergePR: (projectId: string, prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise; assignPR: (projectId: string, prNumber: number, username: string) => Promise; getPRReview: (projectId: string, prNumber: number) => Promise; + getPRReviewsBatch: (projectId: string, prNumbers: number[]) => Promise>; // Follow-up review operations checkNewCommits: (projectId: string, prNumber: number) => Promise; @@ -251,6 +273,10 @@ export interface GitHubAPI { // PR logs getPRLogs: (projectId: string, prNumber: number) => Promise; + // Workflow approval (for fork PRs) + getWorkflowsAwaitingApproval: (projectId: string, prNumber: number) => Promise; + approveWorkflow: (projectId: string, runId: number) => Promise; + // PR event listeners onPRReviewProgress: ( callback: (projectId: string, progress: PRReviewProgress) => void @@ -586,8 +612,11 @@ export const createGitHubAPI = (): GitHubAPI => ({ createIpcListener(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW_ERROR, callback), // PR operations - listPRs: (projectId: string): Promise => - invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId), + listPRs: (projectId: string, page: number = 1): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId, page), + + getPR: (projectId: string, prNumber: number): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber), runPRReview: (projectId: string, prNumber: number): void => sendIpc(IPC_CHANNELS.GITHUB_PR_REVIEW, projectId, prNumber), @@ -595,8 +624,8 @@ export const createGitHubAPI = (): GitHubAPI => ({ cancelPRReview: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_REVIEW_CANCEL, projectId, prNumber), - postPRReview: (projectId: string, prNumber: number, selectedFindingIds?: string[]): Promise => - invokeIpc(IPC_CHANNELS.GITHUB_PR_POST_REVIEW, projectId, prNumber, selectedFindingIds), + postPRReview: (projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_POST_REVIEW, projectId, prNumber, selectedFindingIds, options), deletePRReview: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_DELETE_REVIEW, projectId, prNumber), @@ -613,6 +642,9 @@ export const createGitHubAPI = (): GitHubAPI => ({ getPRReview: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_REVIEW, projectId, prNumber), + getPRReviewsBatch: (projectId: string, prNumbers: number[]): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH, projectId, prNumbers), + // Follow-up review operations checkNewCommits: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_CHECK_NEW_COMMITS, projectId, prNumber), @@ -624,6 +656,13 @@ export const createGitHubAPI = (): GitHubAPI => ({ getPRLogs: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_LOGS, projectId, prNumber), + // Workflow approval (for fork PRs) + getWorkflowsAwaitingApproval: (projectId: string, prNumber: number): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_WORKFLOWS_AWAITING_APPROVAL, projectId, prNumber), + + approveWorkflow: (projectId: string, runId: number): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_WORKFLOW_APPROVE, projectId, runId), + // PR event listeners onPRReviewProgress: ( callback: (projectId: string, progress: PRReviewProgress) => void diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index b4ae053f..d7421373 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -1,5 +1,11 @@ import { ipcRenderer } from 'electron'; import { IPC_CHANNELS } from '../../shared/constants'; + +// Increase max listeners to accommodate 12 terminals with multiple event types +// Each terminal can have listeners for: output, exit, titleChange, claudeSession, etc. +// Default is 10, but with 12 terminals we need more headroom +ipcRenderer.setMaxListeners(50); + import type { IPCResult, TerminalCreateOptions, @@ -28,6 +34,8 @@ export interface TerminalAPI { resizeTerminal: (id: string, cols: number, rows: number) => void; invokeClaudeInTerminal: (id: string, cwd?: string) => void; generateTerminalName: (command: string, cwd?: string) => Promise>; + setTerminalTitle: (id: string, title: string) => void; + setTerminalWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined) => void; // Terminal Session Management getTerminalSessions: (projectPath: string) => Promise>; @@ -65,6 +73,7 @@ export interface TerminalAPI { onTerminalOAuthToken: ( callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void ) => () => void; + onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void; // Claude Profile Management getClaudeProfiles: () => Promise>; @@ -108,6 +117,12 @@ export const createTerminalAPI = (): TerminalAPI => ({ generateTerminalName: (command: string, cwd?: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GENERATE_NAME, command, cwd), + setTerminalTitle: (id: string, title: string): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_SET_TITLE, id, title), + + setTerminalWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_SET_WORKTREE_CONFIG, id, config), + // Terminal Session Management getTerminalSessions: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath), @@ -250,6 +265,22 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onTerminalClaudeBusy: ( + callback: (id: string, isBusy: boolean) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string, + isBusy: boolean + ): void => { + callback(id, isBusy); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, handler); + }; + }, + // Claude Profile Management getClaudeProfiles: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET), diff --git a/apps/frontend/src/renderer/components/AgentProfileSelector.tsx b/apps/frontend/src/renderer/components/AgentProfileSelector.tsx index 6d23cb97..fa74affa 100644 --- a/apps/frontend/src/renderer/components/AgentProfileSelector.tsx +++ b/apps/frontend/src/renderer/components/AgentProfileSelector.tsx @@ -96,23 +96,18 @@ export function AgentProfileSelector({ if (selectedId === 'custom') { // Keep current model/thinking level, just mark as custom onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium'); - } else if (selectedId === 'auto') { - // Auto profile - set defaults - const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto'); - if (autoProfile) { - onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel); - // Initialize phase configs with defaults if callback provided - if (onPhaseModelsChange && autoProfile.phaseModels) { - onPhaseModelsChange(autoProfile.phaseModels); - } - if (onPhaseThinkingChange && autoProfile.phaseThinking) { - onPhaseThinkingChange(autoProfile.phaseThinking); - } - } } else { + // Select preset profile - all profiles now have phase configs const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId); if (profile) { onProfileChange(profile.id, profile.model, profile.thinkingLevel); + // Initialize phase configs with profile defaults if callbacks provided + if (onPhaseModelsChange && profile.phaseModels) { + onPhaseModelsChange(profile.phaseModels); + } + if (onPhaseThinkingChange && profile.phaseThinking) { + onPhaseThinkingChange(profile.phaseThinking); + } } } }; @@ -193,10 +188,7 @@ export function AgentProfileSelector({
{profile.name} - {profile.isAutoProfile - ? '(per-phase optimization)' - : `(${modelLabel} + ${profile.thinkingLevel})` - } + ({modelLabel} + {profile.thinkingLevel})
@@ -221,8 +213,8 @@ export function AgentProfileSelector({

- {/* Auto Profile - Phase Configuration */} - {isAuto && ( + {/* Phase Configuration - shown for all preset profiles */} + {!isCustom && (
{/* Clickable Header */} + + +
+ + + ); + })} + + )} + + {/* Terminal Worktrees Section */} + {terminalWorktrees.length > 0 && ( +
+

+ + Terminal Worktrees +

+ {terminalWorktrees.map((wt) => ( + + +
+
+ + + {wt.name} + + {wt.branchName && ( + + {wt.branchName} + + )} +
+ {wt.taskId && ( + + {wt.taskId} + )}
- - {worktree.specName} - -
- - - {/* Stats */} -
-
- - {worktree.filesChanged} files changed -
-
- - {worktree.commitCount} commits ahead -
-
- - {worktree.additions} -
-
- - {worktree.deletions} -
-
+ + + {/* Branch info */} + {wt.baseBranch && wt.branchName && ( +
+ {wt.baseBranch} + + {wt.branchName} +
+ )} - {/* Branch info */} -
- {worktree.baseBranch} - - {worktree.branch} -
+ {/* Created at */} + {wt.createdAt && ( +
+ Created {new Date(wt.createdAt).toLocaleDateString()} +
+ )} - {/* Actions */} -
- - - -
-
- - ); - })} + {/* Actions */} +
+ + +
+
+ + ))} + + )} )} @@ -474,6 +606,46 @@ export function Worktrees({ projectId }: WorktreesProps) { + + {/* Terminal Worktree Delete Confirmation Dialog */} + !open && setTerminalWorktreeToDelete(null)}> + + + Delete Terminal Worktree? + + This will permanently delete the worktree and its branch. Any uncommitted changes will be lost. + {terminalWorktreeToDelete && ( + + {terminalWorktreeToDelete.name} + {terminalWorktreeToDelete.branchName && ( + ({terminalWorktreeToDelete.branchName}) + )} + + )} + + + + Cancel + + {isDeletingTerminal ? ( + <> + + Deleting... + + ) : ( + <> + + Delete + + )} + + + + ); } diff --git a/apps/frontend/src/renderer/components/context/MemoriesTab.tsx b/apps/frontend/src/renderer/components/context/MemoriesTab.tsx index 53fdb0fe..49304ae8 100644 --- a/apps/frontend/src/renderer/components/context/MemoriesTab.tsx +++ b/apps/frontend/src/renderer/components/context/MemoriesTab.tsx @@ -1,11 +1,16 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { RefreshCw, Database, Brain, Search, CheckCircle, - XCircle + XCircle, + GitPullRequest, + Lightbulb, + FolderTree, + Code, + AlertTriangle } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; @@ -15,8 +20,11 @@ import { ScrollArea } from '../ui/scroll-area'; import { cn } from '../../lib/utils'; import { MemoryCard } from './MemoryCard'; import { InfoItem } from './InfoItem'; +import { memoryFilterCategories } from './constants'; import type { GraphitiMemoryStatus, GraphitiMemoryState, MemoryEpisode } from '../../../shared/types'; +type FilterCategory = keyof typeof memoryFilterCategories; + interface MemoriesTabProps { memoryStatus: GraphitiMemoryStatus | null; memoryState: GraphitiMemoryState | null; @@ -27,6 +35,39 @@ interface MemoriesTabProps { onSearch: (query: string) => void; } +// Helper to check if memory is a PR review (by type or content) +function isPRReview(memory: MemoryEpisode): boolean { + if (['pr_review', 'pr_finding', 'pr_pattern', 'pr_gotcha'].includes(memory.type)) { + return true; + } + try { + const parsed = JSON.parse(memory.content); + return parsed.prNumber !== undefined && parsed.verdict !== undefined; + } catch { + return false; + } +} + +// Get the effective category for a memory +function getMemoryCategory(memory: MemoryEpisode): FilterCategory { + if (isPRReview(memory)) return 'pr'; + if (['session_insight', 'task_outcome'].includes(memory.type)) return 'sessions'; + if (['codebase_discovery', 'codebase_map'].includes(memory.type)) return 'codebase'; + if (['pattern', 'pr_pattern'].includes(memory.type)) return 'patterns'; + if (['gotcha', 'pr_gotcha'].includes(memory.type)) return 'gotchas'; + return 'sessions'; // default +} + +// Filter icons for each category +const filterIcons: Record = { + all: Brain, + pr: GitPullRequest, + sessions: Lightbulb, + codebase: FolderTree, + patterns: Code, + gotchas: AlertTriangle +}; + export function MemoriesTab({ memoryStatus, memoryState, @@ -37,6 +78,32 @@ export function MemoriesTab({ onSearch }: MemoriesTabProps) { const [localSearchQuery, setLocalSearchQuery] = useState(''); + const [activeFilter, setActiveFilter] = useState('all'); + + // Calculate memory counts by category + const memoryCounts = useMemo(() => { + const counts: Record = { + all: recentMemories.length, + pr: 0, + sessions: 0, + codebase: 0, + patterns: 0, + gotchas: 0 + }; + + for (const memory of recentMemories) { + const category = getMemoryCategory(memory); + counts[category]++; + } + + return counts; + }, [recentMemories]); + + // Filter memories based on active filter + const filteredMemories = useMemo(() => { + if (activeFilter === 'all') return recentMemories; + return recentMemories.filter(memory => getMemoryCategory(memory) === activeFilter); + }, [recentMemories, activeFilter]); const handleSearch = () => { if (localSearchQuery.trim()) { @@ -77,17 +144,41 @@ export function MemoriesTab({ {memoryStatus?.available ? ( <> -
+
- - {memoryState && ( - - )} +
- {memoryState?.last_session && ( -

- Last session: #{memoryState.last_session} -

+ + {/* Memory Stats Summary */} + {recentMemories.length > 0 && ( +
+
+
+
{memoryCounts.all}
+
Total
+
+
+
{memoryCounts.pr}
+
PR Reviews
+
+
+
{memoryCounts.sessions}
+
Sessions
+
+
+
{memoryCounts.codebase}
+
Codebase
+
+
+
{memoryCounts.patterns}
+
Patterns
+
+
+
{memoryCounts.gotchas}
+
Gotchas
+
+
+
)} ) : ( @@ -145,30 +236,92 @@ export function MemoriesTab({ )}
- {/* Recent Memories */} + {/* Memory Browser */}
-

- Recent Memories -

+
+

+ Memory Browser +

+ + {filteredMemories.length} of {recentMemories.length} memories + +
+ {/* Filter Pills */} +
+ {(Object.keys(memoryFilterCategories) as FilterCategory[]).map((category) => { + const config = memoryFilterCategories[category]; + const count = memoryCounts[category]; + const Icon = filterIcons[category]; + const isActive = activeFilter === category; + + return ( + + ); + })} +
+ + {/* Memory List */} {memoriesLoading && (
)} - {!memoriesLoading && recentMemories.length === 0 && ( + {!memoriesLoading && filteredMemories.length === 0 && recentMemories.length === 0 && (

- No memories recorded yet. Memories are created during AI agent sessions. + No memories recorded yet. Memories are created during AI agent sessions and PR reviews.

)} - {recentMemories.length > 0 && ( + {!memoriesLoading && filteredMemories.length === 0 && recentMemories.length > 0 && ( +
+ +

+ No memories match the selected filter. +

+ +
+ )} + + {filteredMemories.length > 0 && (
- {recentMemories.map((memory) => ( + {filteredMemories.map((memory) => ( ))}
diff --git a/apps/frontend/src/renderer/components/context/MemoryCard.tsx b/apps/frontend/src/renderer/components/context/MemoryCard.tsx index ccb698e4..1da3ede0 100644 --- a/apps/frontend/src/renderer/components/context/MemoryCard.tsx +++ b/apps/frontend/src/renderer/components/context/MemoryCard.tsx @@ -14,8 +14,9 @@ import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; import { Badge } from '../ui/badge'; import type { MemoryEpisode } from '../../../shared/types'; -import { memoryTypeIcons } from './constants'; +import { memoryTypeIcons, memoryTypeColors, memoryTypeLabels } from './constants'; import { formatDate } from './utils'; +import { PRReviewCard } from './PRReviewCard'; interface MemoryCardProps { memory: MemoryEpisode; @@ -88,13 +89,28 @@ function ListItem({ children, variant = 'default' }: { children: React.ReactNode ); } +// Check if memory content looks like a PR review +function isPRReviewMemory(memory: MemoryEpisode): boolean { + // Check by type first + if (memory.type === 'pr_review' || memory.type === 'pr_finding' || + memory.type === 'pr_pattern' || memory.type === 'pr_gotcha') { + return true; + } + + // Check by content structure (for session_insight type that's actually a PR review) + try { + const parsed = JSON.parse(memory.content); + return parsed.prNumber !== undefined && parsed.verdict !== undefined; + } catch { + return false; + } +} + export function MemoryCard({ memory }: MemoryCardProps) { - const Icon = memoryTypeIcons[memory.type] || memoryTypeIcons.session_insight; const [expanded, setExpanded] = useState(false); - const parsed = useMemo(() => parseMemoryContent(memory.content), [memory.content]); - - // Determine if there's meaningful content to show + + // Determine if there's meaningful content to show (must be called before early return) const hasContent = useMemo(() => { if (!parsed) return false; const d = parsed.discoveries || {}; @@ -110,6 +126,15 @@ export function MemoryCard({ memory }: MemoryCardProps) { ); }, [parsed]); + // Delegate PR reviews to specialized component + if (isPRReviewMemory(memory)) { + return ; + } + + const Icon = memoryTypeIcons[memory.type] || memoryTypeIcons.session_insight; + const typeColor = memoryTypeColors[memory.type] || ''; + const typeLabel = memoryTypeLabels[memory.type] || memory.type.replace(/_/g, ' '); + const sessionLabel = memory.session_number ? `Session #${memory.session_number}` : parsed?.session_number @@ -129,8 +154,8 @@ export function MemoryCard({ memory }: MemoryCardProps) {
- - {memory.type.replace(/_/g, ' ')} + + {typeLabel} {sessionLabel && ( diff --git a/apps/frontend/src/renderer/components/context/PRReviewCard.tsx b/apps/frontend/src/renderer/components/context/PRReviewCard.tsx new file mode 100644 index 00000000..c6a4a8b4 --- /dev/null +++ b/apps/frontend/src/renderer/components/context/PRReviewCard.tsx @@ -0,0 +1,319 @@ +import { useState, useMemo } from 'react'; +import { + Clock, + GitPullRequest, + CheckCircle, + XCircle, + MessageSquare, + ChevronDown, + ChevronUp, + AlertTriangle, + Bug, + Sparkles, + ExternalLink +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Card, CardContent } from '../ui/card'; +import { Badge } from '../ui/badge'; +import type { MemoryEpisode } from '../../../shared/types'; +import { formatDate } from './utils'; + +interface PRReviewCardProps { + memory: MemoryEpisode; +} + +interface ParsedPRReview { + prNumber: number; + repo: string; + verdict: 'approve' | 'request_changes' | 'comment'; + timestamp: string; + summary: { + verdict: string; + finding_counts: { + critical: number; + high: number; + medium: number; + low: number; + }; + total_findings: number; + }; + keyFindings: Array<{ + severity: string; + message: string; + file?: string; + line?: number; + }>; + patterns: string[]; + gotchas: string[]; + isFollowup: boolean; + previousReviews?: number; +} + +function parsePRReviewContent(content: string): ParsedPRReview | null { + try { + return JSON.parse(content); + } catch { + return null; + } +} + +function VerdictBadge({ verdict }: { verdict: string }) { + switch (verdict) { + case 'approve': + return ( + + + Approved + + ); + case 'request_changes': + return ( + + + Changes Requested + + ); + case 'comment': + return ( + + + Commented + + ); + default: + return ( + + {verdict} + + ); + } +} + +function SeverityBadge({ severity, count }: { severity: string; count: number }) { + if (count === 0) return null; + + const colorMap: Record = { + critical: 'bg-red-600/20 text-red-400 border-red-600/30', + high: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + medium: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + low: 'bg-blue-500/20 text-blue-400 border-blue-500/30' + }; + + return ( + + {count} {severity} + + ); +} + +export function PRReviewCard({ memory }: PRReviewCardProps) { + const [expanded, setExpanded] = useState(false); + const parsed = useMemo(() => parsePRReviewContent(memory.content), [memory.content]); + + if (!parsed) { + // Fallback for non-parseable content + return ( + + +
+ + PR Review + {formatDate(memory.timestamp)} +
+
+            {memory.content}
+          
+
+
+ ); + } + + const { finding_counts } = parsed.summary || { finding_counts: { critical: 0, high: 0, medium: 0, low: 0 } }; + const totalFindings = (finding_counts?.critical || 0) + (finding_counts?.high || 0) + + (finding_counts?.medium || 0) + (finding_counts?.low || 0); + const hasGotchas = parsed.gotchas && parsed.gotchas.length > 0; + const hasPatterns = parsed.patterns && parsed.patterns.length > 0; + const hasFindings = parsed.keyFindings && parsed.keyFindings.length > 0; + const hasExpandableContent = hasGotchas || hasPatterns || hasFindings; + + return ( + + + {/* Header */} +
+
+
+ +
+
+ {/* PR Info Row */} +
+ + PR #{parsed.prNumber} + + + {parsed.repo} + + {parsed.isFollowup && ( + + Follow-up + + )} +
+ + {/* Verdict & Stats Row */} +
+ + {totalFindings > 0 && ( + + {totalFindings} finding{totalFindings !== 1 ? 's' : ''} + + )} +
+ + {/* Severity Breakdown */} + {totalFindings > 0 && ( +
+ + + + +
+ )} + + {/* Timestamp */} +
+ + {formatDate(memory.timestamp)} +
+
+
+ + {/* Expand Button */} + {hasExpandableContent && ( + + )} +
+ + {/* Expanded Content */} + {expanded && ( +
+ {/* Key Findings */} + {hasFindings && ( +
+
+ + Key Findings + + {parsed.keyFindings.length} + +
+
+ {parsed.keyFindings.slice(0, 5).map((finding, idx) => ( +
+
+ + {finding.severity} + + {finding.file && ( + + {finding.file}{finding.line ? `:${finding.line}` : ''} + + )} +
+

{finding.message}

+
+ ))} + {parsed.keyFindings.length > 5 && ( +

+ +{parsed.keyFindings.length - 5} more findings +

+ )} +
+
+ )} + + {/* Gotchas */} + {hasGotchas && ( +
+
+ + Gotchas Discovered + + {parsed.gotchas.length} + +
+
    + {parsed.gotchas.map((gotcha, idx) => ( +
  • + {gotcha} +
  • + ))} +
+
+ )} + + {/* Patterns */} + {hasPatterns && ( +
+
+ + Patterns Identified + + {parsed.patterns.length} + +
+
+ {parsed.patterns.map((pattern, idx) => ( + + {pattern} + + ))} +
+
+ )} + + {/* Link to PR */} + {parsed.repo && parsed.prNumber && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/context/constants.ts b/apps/frontend/src/renderer/components/context/constants.ts index 963afc36..3905d069 100644 --- a/apps/frontend/src/renderer/components/context/constants.ts +++ b/apps/frontend/src/renderer/components/context/constants.ts @@ -10,7 +10,11 @@ import { FolderTree, AlertTriangle, Smartphone, - Monitor + Monitor, + GitPullRequest, + Bug, + Sparkles, + Target } from 'lucide-react'; // Service type icon mapping @@ -45,5 +49,54 @@ export const memoryTypeIcons: Record = { codebase_discovery: FolderTree, codebase_map: FolderTree, pattern: Code, - gotcha: AlertTriangle + gotcha: AlertTriangle, + task_outcome: Target, + qa_result: Target, + historical_context: Lightbulb, + pr_review: GitPullRequest, + pr_finding: Bug, + pr_pattern: Sparkles, + pr_gotcha: AlertTriangle +}; + +// Memory type colors for badges and styling +export const memoryTypeColors: Record = { + session_insight: 'bg-amber-500/10 text-amber-400 border-amber-500/30', + codebase_discovery: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + codebase_map: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + pattern: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + gotcha: 'bg-red-500/10 text-red-400 border-red-500/30', + task_outcome: 'bg-green-500/10 text-green-400 border-green-500/30', + qa_result: 'bg-teal-500/10 text-teal-400 border-teal-500/30', + historical_context: 'bg-slate-500/10 text-slate-400 border-slate-500/30', + pr_review: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30', + pr_finding: 'bg-orange-500/10 text-orange-400 border-orange-500/30', + pr_pattern: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + pr_gotcha: 'bg-red-500/10 text-red-400 border-red-500/30' +}; + +// Memory type labels for display +export const memoryTypeLabels: Record = { + session_insight: 'Session Insight', + codebase_discovery: 'Codebase Discovery', + codebase_map: 'Codebase Map', + pattern: 'Pattern', + gotcha: 'Gotcha', + task_outcome: 'Task Outcome', + qa_result: 'QA Result', + historical_context: 'Historical Context', + pr_review: 'PR Review', + pr_finding: 'PR Finding', + pr_pattern: 'PR Pattern', + pr_gotcha: 'PR Gotcha' +}; + +// Filter categories for grouping memory types +export const memoryFilterCategories = { + all: { label: 'All', types: [] as string[] }, + pr: { label: 'PR Reviews', types: ['pr_review', 'pr_finding', 'pr_pattern', 'pr_gotcha'] }, + sessions: { label: 'Sessions', types: ['session_insight', 'task_outcome', 'qa_result', 'historical_context'] }, + codebase: { label: 'Codebase', types: ['codebase_discovery', 'codebase_map'] }, + patterns: { label: 'Patterns', types: ['pattern', 'pr_pattern'] }, + gotchas: { label: 'Gotchas', types: ['gotcha', 'pr_gotcha'] } }; diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index 553d89d5..00abe6ed 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -60,11 +60,14 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) const { prs, isLoading, + isLoadingMore, + isLoadingPRDetails, error, selectedPRNumber, reviewResult, reviewProgress, isReviewing, + hasMore, selectPR, runReview, runFollowupReview, @@ -75,10 +78,11 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) mergePR, assignPR, refresh, + loadMore, isConnected, repoFullName, getReviewStateForPR, - } = useGitHubPRs(selectedProject?.id); + } = useGitHubPRs(selectedProject?.id, { isActive }); const selectedPR = prs.find(pr => pr.number === selectedPRNumber); @@ -126,9 +130,9 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) } }, [selectedPRNumber, cancelReview]); - const handlePostReview = useCallback(async (selectedFindingIds?: string[]): Promise => { + const handlePostReview = useCallback(async (selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise => { if (selectedPRNumber && reviewResult) { - return await postReview(selectedPRNumber, selectedFindingIds); + return await postReview(selectedPRNumber, selectedFindingIds, options); } return false; }, [selectedPRNumber, reviewResult, postReview]); @@ -218,9 +222,12 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) prs={filteredPRs} selectedPRNumber={selectedPRNumber} isLoading={isLoading} + isLoadingMore={isLoadingMore} + hasMore={hasMore} error={error} getReviewStateForPR={getReviewStateForPR} onSelectPR={selectPR} + onLoadMore={loadMore} />
} @@ -234,6 +241,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) isReviewing={isReviewing} initialNewCommitsCheck={storedNewCommitsCheck} isActive={isActive} + isLoadingFiles={isLoadingPRDetails} onRunReview={handleRunReview} onRunFollowupReview={handleRunFollowupReview} onCheckNewCommits={handleCheckNewCommits} diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx index 8f2c713b..b33328b6 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx @@ -13,13 +13,15 @@ import { CheckCheck, MessageSquare, FileText, + ExternalLink, + Play, + Clock, } from 'lucide-react'; import { Badge } from '../../ui/badge'; import { Button } from '../../ui/button'; import { Card, CardContent } from '../../ui/card'; import { ScrollArea } from '../../ui/scroll-area'; import { Progress } from '../../ui/progress'; -import { formatDate } from '../utils/formatDate'; // Local components import { CollapsibleCard } from './CollapsibleCard'; @@ -29,7 +31,7 @@ import { ReviewFindings } from './ReviewFindings'; import { PRLogs } from './PRLogs'; import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs'; -import type { NewCommitsCheck, PRLogs as PRLogsType } from '../../../../preload/api/modules/github-api'; +import type { NewCommitsCheck, PRLogs as PRLogsType, WorkflowsAwaitingApprovalResult } from '../../../../preload/api/modules/github-api'; interface PRDetailProps { pr: PRData; @@ -39,11 +41,12 @@ interface PRDetailProps { isReviewing: boolean; initialNewCommitsCheck?: NewCommitsCheck | null; isActive?: boolean; + isLoadingFiles?: boolean; onRunReview: () => void; onRunFollowupReview: () => void; onCheckNewCommits: () => Promise; onCancelReview: () => void; - onPostReview: (selectedFindingIds?: string[]) => Promise; + onPostReview: (selectedFindingIds?: string[], options?: { forceApprove?: boolean }) => Promise; onPostComment: (body: string) => void; onMergePR: (mergeMethod?: 'merge' | 'squash' | 'rebase') => void; onAssignPR: (username: string) => void; @@ -68,7 +71,8 @@ export function PRDetail({ reviewProgress, isReviewing, initialNewCommitsCheck, - isActive = false, + isActive: _isActive = false, + isLoadingFiles = false, onRunReview, onRunFollowupReview, onCheckNewCommits, @@ -79,7 +83,7 @@ export function PRDetail({ onAssignPR: _onAssignPR, onGetLogs, }: PRDetailProps) { - const { t, i18n } = useTranslation('common'); + const { t } = useTranslation('common'); // Selection state for findings const [selectedFindingIds, setSelectedFindingIds] = useState>(new Set()); const [postedFindingIds, setPostedFindingIds] = useState>(new Set()); @@ -99,6 +103,11 @@ export function PRDetail({ const [isLoadingLogs, setIsLoadingLogs] = useState(false); const logsLoadedRef = useRef(false); + // Workflows awaiting approval state (for fork PRs) + const [workflowsAwaiting, setWorkflowsAwaiting] = useState(null); + const [isApprovingWorkflow, setIsApprovingWorkflow] = useState(null); + const [workflowsExpanded, setWorkflowsExpanded] = useState(true); + // Sync with store's newCommitsCheck when it changes (e.g., when switching PRs or after refresh) // Always sync to keep local state in sync with store, including null values useEffect(() => { @@ -242,6 +251,58 @@ export function PRDetail({ setLogsExpanded(false); }, [pr.number]); + // Check for workflows awaiting approval (fork PRs) when PR changes or review completes + useEffect(() => { + const checkWorkflows = async () => { + try { + const result = await window.electronAPI.github.getWorkflowsAwaitingApproval( + '', // projectId will be resolved from active project + pr.number + ); + setWorkflowsAwaiting(result); + } catch { + setWorkflowsAwaiting(null); + } + }; + + checkWorkflows(); + // Re-check when a review is completed (CI status might have changed) + }, [pr.number, reviewResult]); + + // Handler to approve a workflow + const handleApproveWorkflow = useCallback(async (runId: number) => { + setIsApprovingWorkflow(runId); + try { + const success = await window.electronAPI.github.approveWorkflow('', runId); + if (success) { + // Refresh the workflows list after approval + const result = await window.electronAPI.github.getWorkflowsAwaitingApproval('', pr.number); + setWorkflowsAwaiting(result); + } + } finally { + setIsApprovingWorkflow(null); + } + }, [pr.number]); + + // Handler to approve all workflows at once + const handleApproveAllWorkflows = useCallback(async () => { + if (!workflowsAwaiting?.workflow_runs.length) return; + + for (const workflow of workflowsAwaiting.workflow_runs) { + setIsApprovingWorkflow(workflow.id); + try { + await window.electronAPI.github.approveWorkflow('', workflow.id); + } catch { + // Continue with other workflows even if one fails + } + } + setIsApprovingWorkflow(null); + + // Refresh the workflows list + const result = await window.electronAPI.github.getWorkflowsAwaitingApproval('', pr.number); + setWorkflowsAwaiting(result); + }, [pr.number, workflowsAwaiting]); + // Count selected findings by type for the button label const selectedCount = selectedFindingIds.size; @@ -458,7 +519,7 @@ export function PRDetail({ } }; - // Auto-approval for clean PRs - posts LOW findings as suggestions + approval comment + // Auto-approval for clean PRs - posts approval with LOW findings as suggestions in a SINGLE comment // NOTE: GitHub PR comments are intentionally in English as it's the lingua franca // for code reviews and GitHub's international developer community. The comment // content is meant to be read by contributors who may have different locales. @@ -466,40 +527,15 @@ export function PRDetail({ if (!reviewResult) return; setIsPosting(true); try { - // Step 1: Post any LOW findings as non-blocking suggestions + // Post approval with suggestions in a single review comment + // This uses forceApprove to set APPROVE status even with LOW findings const lowFindingIds = lowSeverityFindings.map(f => f.id); - if (lowFindingIds.length > 0) { - const success = await onPostReview(lowFindingIds); - if (!success) { - // Failed to post findings, don't proceed with approval - return; - } - // Mark them as posted locally + + const success = await onPostReview(lowFindingIds, { forceApprove: true }); + if (success && lowFindingIds.length > 0) { + // Mark findings as posted locally setPostedFindingIds(prev => new Set([...prev, ...lowFindingIds])); } - - // Step 2: Post the approval comment - const findingsNote = lowFindingIds.length > 0 - ? `- ${lowFindingIds.length} low-severity suggestion${lowFindingIds.length !== 1 ? 's' : ''} posted above` - : '- No issues found'; - - const approvalMessage = `## Auto Claude Review - APPROVED - -**Status:** Ready to Merge - -**Summary:** ${reviewResult.summary} - ---- -**Review Details:** -${findingsNote} -- Reviewed at: ${formatDate(reviewResult.reviewedAt, i18n.language)} -${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking issues resolved` : ''} - -*This automated review found no blocking issues. The PR can be safely merged.* - ---- -*Generated by Auto Claude*`; - await onPostComment(approvalMessage); } finally { setIsPosting(false); } @@ -519,7 +555,7 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss
{/* Refactored Header */} - + {/* Review Status & Actions */} )} - {/* Auto-approve for clean PRs (only LOW findings or no findings) */} - {isCleanReview && ( + {/* Approve button - consolidated logic to avoid duplicate buttons */} + {/* Don't show when overallStatus is 'request_changes' (e.g., workflows blocked, or other issues) */} + {isCleanReview && !hasPostedFindings && reviewResult?.overallStatus !== 'request_changes' && ( - - + {/* Manual approve button - only show for non-clean reviews that are ready to merge */} + {/* isReadyToMerge already checks for 'approve' status, so no need for additional check */} + {isReadyToMerge && !isCleanReview && !hasPostedFindings && ( + + )} + + {/* Merge button - only show after approval has been posted */} + {hasPostedFindings && ( + )} {postSuccess && ( @@ -646,7 +695,7 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss
{/* Follow-up Review Resolution Status */} {reviewResult.isFollowupReview && ( -
+
{(reviewResult.resolvedFindings?.length ?? 0) > 0 && ( @@ -665,6 +714,21 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss {t('prReview.newIssue', { count: reviewResult.newFindingsSinceLastReview?.length ?? 0 })} )} + {/* Re-run follow-up review button */} +
)} @@ -698,6 +762,94 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss )} + {/* Workflows Awaiting Approval - for fork PRs */} + {workflowsAwaiting && workflowsAwaiting.awaiting_approval > 0 && ( + } + badge={ + + + {t('prReview.blockedByWorkflows')} + + } + open={workflowsExpanded} + onOpenChange={setWorkflowsExpanded} + > +
+

+ {t('prReview.workflowsAwaitingDescription')} +

+ +
+ {workflowsAwaiting.workflow_runs.map((workflow) => ( +
+
+ +
+ + {workflow.workflow_name} + + + {workflow.name} + +
+
+
+ + +
+
+ ))} +
+ + {workflowsAwaiting.workflow_runs.length > 1 && ( +
+ +
+ )} +
+
+ )} + {/* Review Logs - show during review or after completion */} {(reviewResult || isReviewing) && ( 0; return (
@@ -69,11 +93,27 @@ export function PRHeader({ pr }: PRHeaderProps) {
-
- + {/* Clickable files indicator */} +
+ {hasFiles && ( + showFiles ? : + )} +
+{pr.additions} @@ -84,6 +124,52 @@ export function PRHeader({ pr }: PRHeaderProps) {
+ + {/* Collapsible file list */} + {showFiles && ( +
+ {isLoadingFiles ? ( +
+ + {t('prReview.loadingFiles')} +
+ ) : hasFiles ? ( +
+ {pr.files.map((file, index) => ( +
+ + + {file.path} + + + {file.status} + +
+ + + {file.additions} + + + + {file.deletions} + +
+
+ ))} +
+ ) : ( +
+ {t('prReview.noFilesAvailable')} +
+ )} +
+ )}
); } diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index c4f67ba4..8ee07675 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -1,4 +1,5 @@ -import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react'; +import { useRef, useEffect, useCallback } from 'react'; +import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { Badge } from '../../ui/badge'; import { cn } from '../../../lib/utils'; @@ -166,9 +167,12 @@ interface PRListProps { prs: PRData[]; selectedPRNumber: number | null; isLoading: boolean; + isLoadingMore: boolean; + hasMore: boolean; error: string | null; getReviewStateForPR: (prNumber: number) => PRReviewInfo | null; onSelectPR: (prNumber: number) => void; + onLoadMore: () => void; } function formatDate(dateString: string): string { @@ -191,8 +195,45 @@ function formatDate(dateString: string): string { return date.toLocaleDateString(); } -export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewStateForPR, onSelectPR }: PRListProps) { +export function PRList({ + prs, + selectedPRNumber, + isLoading, + isLoadingMore, + hasMore, + error, + getReviewStateForPR, + onSelectPR, + onLoadMore +}: PRListProps) { const { t } = useTranslation('common'); + const scrollAreaRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + + // Intersection Observer for infinite scroll + const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting && hasMore && !isLoadingMore && !isLoading) { + onLoadMore(); + } + }, [hasMore, isLoadingMore, isLoading, onLoadMore]); + + useEffect(() => { + const trigger = loadMoreTriggerRef.current; + if (!trigger) return; + + const observer = new IntersectionObserver(handleIntersection, { + root: null, // Use viewport as root + rootMargin: '100px', // Start loading 100px before reaching the bottom + threshold: 0 + }); + + observer.observe(trigger); + + return () => { + observer.disconnect(); + }; + }, [handleIntersection]); if (isLoading && prs.length === 0) { return ( @@ -227,7 +268,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState } return ( - +
{prs.map((pr) => { const reviewState = getReviewStateForPR(pr.number); @@ -296,6 +337,24 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState ); })} + + {/* Load more trigger / Loading indicator */} +
+ {isLoadingMore ? ( +
+ + {t('prReview.loadingMore')} +
+ ) : hasMore ? ( + + {t('prReview.scrollForMore')} + + ) : prs.length > 0 ? ( + + {t('prReview.allPRsLoaded')} + + ) : null} +
); diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 62e4cc4e..4023be63 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import type { PRData, PRReviewResult, @@ -11,9 +11,16 @@ import { usePRReviewStore, startPRReview as storeStartPRReview, startFollowupRev export type { PRData, PRReviewResult, PRReviewProgress }; export type { PRReviewFinding } from '../../../../preload/api/modules/github-api'; +interface UseGitHubPRsOptions { + /** Whether the component is currently active/visible */ + isActive?: boolean; +} + interface UseGitHubPRsResult { prs: PRData[]; isLoading: boolean; + isLoadingMore: boolean; + isLoadingPRDetails: boolean; // Loading full PR details including files error: string | null; selectedPR: PRData | null; selectedPRNumber: number | null; @@ -23,26 +30,39 @@ interface UseGitHubPRsResult { isConnected: boolean; repoFullName: string | null; activePRReviews: number[]; // PR numbers currently being reviewed + hasMore: boolean; // Whether there are more PRs to load selectPR: (prNumber: number | null) => void; refresh: () => Promise; + loadMore: () => Promise; runReview: (prNumber: number) => Promise; runFollowupReview: (prNumber: number) => Promise; checkNewCommits: (prNumber: number) => Promise; cancelReview: (prNumber: number) => Promise; - postReview: (prNumber: number, selectedFindingIds?: string[]) => Promise; + postReview: (prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }) => Promise; postComment: (prNumber: number, body: string) => Promise; mergePR: (prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise; assignPR: (prNumber: number, username: string) => Promise; getReviewStateForPR: (prNumber: number) => { isReviewing: boolean; progress: PRReviewProgress | null; result: PRReviewResult | null; previousResult: PRReviewResult | null; error: string | null; newCommitsCheck?: NewCommitsCheck | null } | null; } -export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { +export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions = {}): UseGitHubPRsResult { + const { isActive = true } = options; const [prs, setPrs] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [isLoadingPRDetails, setIsLoadingPRDetails] = useState(false); const [error, setError] = useState(null); const [selectedPRNumber, setSelectedPRNumber] = useState(null); + const [selectedPRDetails, setSelectedPRDetails] = useState(null); const [isConnected, setIsConnected] = useState(false); const [repoFullName, setRepoFullName] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + + // Track previous isActive state to detect tab navigation + const wasActiveRef = useRef(isActive); + // Track if initial load has happened + const hasLoadedRef = useRef(false); // Get PR review state from the global store const prReviews = usePRReviewStore((state) => state.prReviews); @@ -82,13 +102,18 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { }; }, [projectId, prReviews, getPRReviewState]); - const selectedPR = prs.find(pr => pr.number === selectedPRNumber) || null; + // Use detailed PR data if available (includes files), otherwise fall back to list data + const selectedPR = selectedPRDetails || prs.find(pr => pr.number === selectedPRNumber) || null; // Check connection and fetch PRs - const fetchPRs = useCallback(async () => { + const fetchPRs = useCallback(async (page: number = 1, append: boolean = false) => { if (!projectId) return; - setIsLoading(true); + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } setError(null); try { @@ -99,55 +124,44 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { setRepoFullName(connectionResult.data.repoFullName || null); if (connectionResult.data.connected) { - // Fetch PRs - const result = await window.electronAPI.github.listPRs(projectId); + // Fetch PRs with pagination + const result = await window.electronAPI.github.listPRs(projectId, page); if (result) { - setPrs(result); + // Check if there are more PRs to load (GitHub returns up to 100 per page) + setHasMore(result.length === 100); + setCurrentPage(page); - // Preload review results for all PRs - const preloadPromises = result.map(async (pr) => { + if (append) { + // Append to existing PRs, deduplicating by PR number + setPrs(prevPrs => { + const existingNumbers = new Set(prevPrs.map(pr => pr.number)); + const newPrs = result.filter(pr => !existingNumbers.has(pr.number)); + return [...prevPrs, ...newPrs]; + }); + } else { + setPrs(result); + } + + // Batch preload review results for PRs not in store (single IPC call) + const prsNeedingPreload = result.filter(pr => { const existingState = getPRReviewState(projectId, pr.number); - // Only fetch from disk if we don't have a result in the store - if (!existingState?.result) { - const reviewResult = await window.electronAPI.github.getPRReview(projectId, pr.number); - if (reviewResult) { - // Update store with the loaded result - // Preserve newCommitsCheck during preload to avoid race condition with new commits check - usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, { preserveNewCommitsCheck: true }); - return { prNumber: pr.number, reviewResult }; - } - } else { - return { prNumber: pr.number, reviewResult: existingState.result }; - } - return null; + return !existingState?.result; }); - // 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); - // Use the action from the hook subscription to ensure proper React re-renders - setNewCommitsCheckAction(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); - } - }) - ); + if (prsNeedingPreload.length > 0) { + const prNumbers = prsNeedingPreload.map(pr => pr.number); + const batchReviews = await window.electronAPI.github.getPRReviewsBatch(projectId, prNumbers); + + // Update store with loaded results + for (const reviewResult of Object.values(batchReviews)) { + if (reviewResult) { + usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, { preserveNewCommitsCheck: true }); + } + } } + + // Note: New commits check is now lazy - only done when user selects a PR + // or explicitly triggers a check. This significantly speeds up list loading. } } } else { @@ -160,12 +174,39 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { setIsConnected(false); } finally { setIsLoading(false); + setIsLoadingMore(false); } }, [projectId, getPRReviewState, setNewCommitsCheckAction]); + // Initial load useEffect(() => { - fetchPRs(); - }, [fetchPRs]); + if (projectId && !hasLoadedRef.current) { + hasLoadedRef.current = true; + fetchPRs(1, false); + } + }, [projectId, fetchPRs]); + + // Auto-refresh when tab becomes active (navigating to GitHub PRs tab) + useEffect(() => { + // Only refresh if transitioning from inactive to active AND we've loaded before + if (isActive && !wasActiveRef.current && hasLoadedRef.current) { + // Reset to first page and refresh + setCurrentPage(1); + setHasMore(true); + fetchPRs(1, false); + } + wasActiveRef.current = isActive; + }, [isActive, fetchPRs]); + + // Reset pagination and selected PR when project changes + useEffect(() => { + hasLoadedRef.current = false; + setCurrentPage(1); + setHasMore(true); + setPrs([]); + setSelectedPRNumber(null); + setSelectedPRDetails(null); + }, [projectId]); // No need for local IPC listeners - they're handled globally in github-store @@ -174,8 +215,29 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { // Note: Don't reset review result - it comes from the store now // and persists across navigation - // Load existing review from disk if not already in store + // Clear previous detailed PR data when deselecting + if (prNumber === null) { + setSelectedPRDetails(null); + return; + } + if (prNumber && projectId) { + // Fetch full PR details including files + setIsLoadingPRDetails(true); + window.electronAPI.github.getPR(projectId, prNumber) + .then(prDetails => { + if (prDetails) { + setSelectedPRDetails(prDetails); + } + }) + .catch(err => { + console.warn(`Failed to fetch PR details for #${prNumber}:`, err); + }) + .finally(() => { + setIsLoadingPRDetails(false); + }); + + // Load existing review from disk if not already in store const existingState = getPRReviewState(projectId, prNumber); // Only fetch from disk if we don't have a result in the store if (!existingState?.result) { @@ -184,16 +246,43 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { // Update store with the loaded result // Preserve newCommitsCheck when loading existing review from disk usePRReviewStore.getState().setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true }); + + // Check for new commits if this PR has been reviewed (lazy check on selection) + const reviewedCommitSha = result.reviewedCommitSha || (result as any).reviewed_commit_sha; + if (reviewedCommitSha && !existingState?.newCommitsCheck) { + window.electronAPI.github.checkNewCommits(projectId, prNumber).then(newCommitsResult => { + setNewCommitsCheckAction(projectId, prNumber, newCommitsResult); + }).catch(err => { + console.warn(`Failed to check new commits for PR #${prNumber}:`, err); + }); + } } }); + } else if (existingState?.result && !existingState?.newCommitsCheck) { + // Review already in store but no new commits check yet - do it now + const reviewedCommitSha = existingState.result.reviewedCommitSha || (existingState.result as any).reviewed_commit_sha; + if (reviewedCommitSha) { + window.electronAPI.github.checkNewCommits(projectId, prNumber).then(newCommitsResult => { + setNewCommitsCheckAction(projectId, prNumber, newCommitsResult); + }).catch(err => { + console.warn(`Failed to check new commits for PR #${prNumber}:`, err); + }); + } } } - }, [projectId, getPRReviewState]); + }, [projectId, getPRReviewState, setNewCommitsCheckAction]); const refresh = useCallback(async () => { - await fetchPRs(); + setCurrentPage(1); + setHasMore(true); + await fetchPRs(1, false); }, [fetchPRs]); + const loadMore = useCallback(async () => { + if (!hasMore || isLoadingMore || isLoading) return; + await fetchPRs(currentPage + 1, true); + }, [fetchPRs, hasMore, isLoadingMore, isLoading, currentPage]); + const runReview = useCallback(async (prNumber: number) => { if (!projectId) return; @@ -241,11 +330,11 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { } }, [projectId]); - const postReview = useCallback(async (prNumber: number, selectedFindingIds?: string[]): Promise => { + const postReview = useCallback(async (prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise => { if (!projectId) return false; try { - const success = await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds); + const success = await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds, options); if (success) { // Reload review result to get updated postedAt and finding status const result = await window.electronAPI.github.getPRReview(projectId, prNumber); @@ -307,6 +396,8 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { return { prs, isLoading, + isLoadingMore, + isLoadingPRDetails, error, selectedPR, selectedPRNumber, @@ -316,8 +407,10 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { isConnected, repoFullName, activePRReviews, + hasMore, selectPR, refresh, + loadMore, runReview, runFollowupReview, checkNewCommits, diff --git a/apps/frontend/src/renderer/components/onboarding/GraphitiStep.tsx b/apps/frontend/src/renderer/components/onboarding/GraphitiStep.tsx index b9f57654..dca6465f 100644 --- a/apps/frontend/src/renderer/components/onboarding/GraphitiStep.tsx +++ b/apps/frontend/src/renderer/components/onboarding/GraphitiStep.tsx @@ -111,7 +111,7 @@ interface ValidationStatus { export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) { const { settings, updateSettings } = useSettingsStore(); const [config, setConfig] = useState({ - enabled: false, + enabled: true, // Enabled by default for better first-time experience database: 'auto_claude_memory', dbPath: '', llmProvider: 'openai', diff --git a/apps/frontend/src/renderer/components/onboarding/MemoryStep.tsx b/apps/frontend/src/renderer/components/onboarding/MemoryStep.tsx index 1cab32a0..0c792c6c 100644 --- a/apps/frontend/src/renderer/components/onboarding/MemoryStep.tsx +++ b/apps/frontend/src/renderer/components/onboarding/MemoryStep.tsx @@ -1,18 +1,18 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { - Brain, Database, Info, Loader2, - CheckCircle2, - AlertCircle, Eye, EyeOff, + ExternalLink, } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; -import { Card, CardContent } from '../ui/card'; +import { Switch } from '../ui/switch'; +import { Separator } from '../ui/separator'; import { Select, SelectContent, @@ -29,22 +29,10 @@ interface MemoryStepProps { onBack: () => void; } -// Embedding provider configurations (LLM provider removed - Claude SDK handles RAG) -const EMBEDDING_PROVIDERS: Array<{ - id: GraphitiEmbeddingProvider; - name: string; - description: string; - requiresApiKey: boolean; -}> = [ - { id: 'ollama', name: 'Ollama (Local)', description: 'Free, local embeddings', requiresApiKey: false }, - { id: 'openai', name: 'OpenAI', description: 'text-embedding-3-small', requiresApiKey: true }, - { id: 'voyage', name: 'Voyage AI', description: 'voyage-3 (high quality)', requiresApiKey: true }, - { id: 'google', name: 'Google AI', description: 'text-embedding-004', requiresApiKey: true }, - { id: 'azure_openai', name: 'Azure OpenAI', description: 'Enterprise deployment', requiresApiKey: true }, -]; - interface MemoryConfig { - database: string; + enabled: boolean; + agentMemoryEnabled: boolean; + mcpServerUrl: string; embeddingProvider: GraphitiEmbeddingProvider; // OpenAI openaiApiKey: string; @@ -57,7 +45,6 @@ interface MemoryConfig { // Google googleApiKey: string; // Ollama - ollamaBaseUrl: string; ollamaEmbeddingModel: string; ollamaEmbeddingDim: number; } @@ -65,16 +52,23 @@ interface MemoryConfig { /** * Memory configuration step for the onboarding wizard. * - * Key simplifications from the previous GraphitiStep: - * - Memory is always enabled (no toggle) - * - LLM provider removed (Claude SDK handles RAG queries) - * - Ollama is the default with model discovery + download - * - Keyword search works as fallback without embeddings + * Matches the settings page Memory section structure: + * - Enable Memory toggle (enabled by default) + * - Enable Agent Memory Access toggle + * - Embedding Provider selection (Ollama default) + * - Provider-specific configuration + * + * Note: LLM provider is not configurable - Claude SDK is used throughout. */ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { + const { t } = useTranslation('onboarding'); const { settings, updateSettings } = useSettingsStore(); + + // Initialize config with memory enabled by default const [config, setConfig] = useState({ - database: 'auto_claude_memory', + enabled: true, // Memory enabled by default + agentMemoryEnabled: true, // Agent memory access enabled by default + mcpServerUrl: 'http://localhost:8000/mcp/', embeddingProvider: 'ollama', openaiApiKey: settings.globalOpenAIApiKey || '', azureOpenaiApiKey: '', @@ -82,25 +76,23 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { azureOpenaiEmbeddingDeployment: '', voyageApiKey: '', googleApiKey: settings.globalGoogleApiKey || '', - ollamaBaseUrl: settings.ollamaBaseUrl || 'http://localhost:11434', ollamaEmbeddingModel: 'qwen3-embedding:4b', ollamaEmbeddingDim: 2560, }); + const [showApiKey, setShowApiKey] = useState>({}); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [isCheckingInfra, setIsCheckingInfra] = useState(true); - const [kuzuAvailable, setKuzuAvailable] = useState(null); // Check LadybugDB/Kuzu availability on mount useEffect(() => { const checkInfrastructure = async () => { setIsCheckingInfra(true); try { - const result = await window.electronAPI.getMemoryInfrastructureStatus(); - setKuzuAvailable(result?.success && result?.data?.memory?.kuzuInstalled ? true : false); + await window.electronAPI.getMemoryInfrastructureStatus(); } catch { - setKuzuAvailable(false); + // Infrastructure will be created automatically when needed } finally { setIsCheckingInfra(false); } @@ -115,6 +107,9 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { // Check if we have valid configuration const isConfigValid = (): boolean => { + // If memory is disabled, always valid + if (!config.enabled) return true; + const { embeddingProvider } = config; // Ollama just needs a model selected @@ -141,15 +136,15 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { try { // Save complete memory configuration to global settings - // This includes all settings needed for backend to use memory const settingsToSave: Record = { - // Core memory settings (CRITICAL - these were missing before) - memoryEnabled: true, + // Core memory settings + memoryEnabled: config.enabled, memoryEmbeddingProvider: config.embeddingProvider, memoryOllamaEmbeddingModel: config.ollamaEmbeddingModel || undefined, memoryOllamaEmbeddingDim: config.ollamaEmbeddingDim || undefined, - // Ollama base URL - ollamaBaseUrl: config.ollamaBaseUrl.trim() || undefined, + // Agent memory access (MCP) + graphitiMcpEnabled: config.agentMemoryEnabled, + graphitiMcpUrl: config.mcpServerUrl.trim() || undefined, // Global API keys (shared across features) globalOpenAIApiKey: config.openaiApiKey.trim() || undefined, globalGoogleApiKey: config.googleApiKey.trim() || undefined, @@ -163,13 +158,14 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { const result = await window.electronAPI.saveSettings(settingsToSave); if (result?.success) { - // Update local settings store with all memory config + // Update local settings store const storeUpdate: Partial = { - memoryEnabled: true, + memoryEnabled: config.enabled, memoryEmbeddingProvider: config.embeddingProvider, memoryOllamaEmbeddingModel: config.ollamaEmbeddingModel || undefined, memoryOllamaEmbeddingDim: config.ollamaEmbeddingDim || undefined, - ollamaBaseUrl: config.ollamaBaseUrl.trim() || undefined, + graphitiMcpEnabled: config.agentMemoryEnabled, + graphitiMcpUrl: config.mcpServerUrl.trim() || undefined, globalOpenAIApiKey: config.openaiApiKey.trim() || undefined, globalGoogleApiKey: config.googleApiKey.trim() || undefined, memoryVoyageApiKey: config.voyageApiKey.trim() || undefined, @@ -189,10 +185,6 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { } }; - const handleContinue = () => { - handleSave(); - }; - const handleOllamaModelSelect = (modelName: string, dim: number) => { setConfig(prev => ({ ...prev, @@ -207,17 +199,13 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { if (embeddingProvider === 'ollama') { return ( -
-
- - -
+
+ +
); } @@ -225,12 +213,10 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { if (embeddingProvider === 'openai') { return (
- + +

{t('memory.openaiApiKeyDescription')}

setConfig(prev => ({ ...prev, openaiApiKey: e.target.value }))} @@ -247,7 +233,7 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {

- Get your key from{' '} + {t('memory.openaiGetKey')}{' '} OpenAI @@ -259,12 +245,10 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { if (embeddingProvider === 'voyage') { return (

- + +

{t('memory.voyageApiKeyDescription')}

setConfig(prev => ({ ...prev, voyageApiKey: e.target.value }))} @@ -281,7 +265,7 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {

- Get your key from{' '} + {t('memory.openaiGetKey')}{' '} Voyage AI @@ -293,12 +277,10 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { if (embeddingProvider === 'google') { return (

- + +

{t('memory.googleApiKeyDescription')}

setConfig(prev => ({ ...prev, googleApiKey: e.target.value }))} @@ -315,7 +297,7 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {

- Get your key from{' '} + {t('memory.openaiGetKey')}{' '} Google AI Studio @@ -327,16 +309,15 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { if (embeddingProvider === 'azure_openai') { return (

-

Azure OpenAI Settings

+
- +
setConfig(prev => ({ ...prev, azureOpenaiApiKey: e.target.value }))} - placeholder="Azure API key" + placeholder="Azure API Key" className="pr-10 font-mono text-sm" disabled={isSaving} /> @@ -349,26 +330,22 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {
-
- +
+ setConfig(prev => ({ ...prev, azureOpenaiBaseUrl: e.target.value }))} - placeholder="https://your-resource.openai.azure.com" className="font-mono text-sm" disabled={isSaving} />
-
- +
+ setConfig(prev => ({ ...prev, azureOpenaiEmbeddingDeployment: e.target.value }))} - placeholder="text-embedding-ada-002" className="font-mono text-sm" disabled={isSaving} /> @@ -387,18 +364,18 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {
- +

- Memory + {t('memory.title')}

- Auto Claude Memory helps remember context across your coding sessions + {t('memory.description')}

- {/* Loading state for infrastructure check */} + {/* Loading state */} {isCheckingInfra && (
@@ -410,112 +387,129 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {
{/* Error banner */} {error && ( - - -
- -

{error}

-
-
-
+
+

{error}

+
)} - {/* Kuzu status notice */} - {kuzuAvailable === false && ( - - + {/* Enable Memory Toggle */} +
+
+ +
+ +

+ {t('memory.enableMemoryDescription')} +

+
+
+ setConfig(prev => ({ ...prev, enabled: checked }))} + disabled={isSaving} + /> +
+ + {/* Memory Disabled Info */} + {!config.enabled && ( +
+
+ +

+ {t('memory.memoryDisabledInfo')} +

+
+
+ )} + + {/* Memory Enabled Configuration */} + {config.enabled && ( + <> + {/* Agent Memory Access Toggle */} +
+
+ +

+ {t('memory.enableAgentAccessDescription')} +

+
+ setConfig(prev => ({ ...prev, agentMemoryEnabled: checked }))} + disabled={isSaving} + /> +
+ + {/* MCP Server URL (shown when agent memory is enabled) */} + {config.agentMemoryEnabled && ( +
+ +

+ {t('memory.mcpServerUrlDescription')} +

+ setConfig(prev => ({ ...prev, mcpServerUrl: e.target.value }))} + className="font-mono text-sm" + disabled={isSaving} + /> +
+ )} + + + + {/* Embedding Provider Selection */} +
+ +

+ {t('memory.embeddingProviderDescription')} +

+ +
+ + {/* Provider-specific fields */} + {renderProviderFields()} + + {/* Info about Learn More */} +
-

- Database will be created automatically -

-

- Memory uses an embedded database - no Docker required. - It will be created when you first use memory features. +

+ {t('memory.memoryInfo')}

+ + {t('memory.learnMore')} + +
- - - )} - - {/* Info card about Memory */} - - -
- -
-

- What does Memory do? -

-

- Memory stores discoveries, patterns, and insights about your codebase - so future sessions start with context already loaded. -

-
    -
  • Remembers patterns across sessions
  • -
  • Understands your codebase over time
  • -
  • Works offline - no cloud required
  • -
-
-
-
- - {/* Database info */} -
- -
-

- Memory Database -

-

- Stored in ~/.auto-claude/memories/ -

-
- {kuzuAvailable && ( - - )} -
- - {/* Embedding Provider Selection */} -
-
- - -
- - {/* Provider-specific fields */} - {renderProviderFields()} -
- - {/* Fallback info */} -

- No embedding provider? Memory still works with keyword search. Semantic search is an upgrade. -

+ + )}
)} @@ -526,21 +520,30 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) { onClick={onBack} className="text-muted-foreground hover:text-foreground" > - Back - - +
+ + +
diff --git a/apps/frontend/src/renderer/components/settings/AgentProfileSettings.tsx b/apps/frontend/src/renderer/components/settings/AgentProfileSettings.tsx index 8b95f848..891c412e 100644 --- a/apps/frontend/src/renderer/components/settings/AgentProfileSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AgentProfileSettings.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'; +import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw, Settings2 } from 'lucide-react'; import { cn } from '../../lib/utils'; import { DEFAULT_AGENT_PROFILES, @@ -29,7 +29,8 @@ const iconMap: Record = { Brain, Scale, Zap, - Sparkles + Sparkles, + Settings2 }; const PHASE_KEYS: Array = ['spec', 'planning', 'coding', 'qa']; @@ -37,45 +38,76 @@ const PHASE_KEYS: Array = ['spec', 'planning', 'coding', /** * Agent Profile Settings component * Displays preset agent profiles for quick model/thinking level configuration - * Used in the Settings page under Agent Settings + * All presets show phase configuration for full customization */ export function AgentProfileSettings() { const { t } = useTranslation('settings'); const settings = useSettingsStore((state) => state.settings); const selectedProfileId = settings.selectedAgentProfile || 'auto'; - const [showPhaseConfig, setShowPhaseConfig] = useState(selectedProfileId === 'auto'); + const [showPhaseConfig, setShowPhaseConfig] = useState(true); - // Get current phase config from settings or defaults - const currentPhaseModels: PhaseModelConfig = settings.customPhaseModels || DEFAULT_PHASE_MODELS; - const currentPhaseThinking: PhaseThinkingConfig = settings.customPhaseThinking || DEFAULT_PHASE_THINKING; + // Find the selected profile + const selectedProfile = useMemo(() => + DEFAULT_AGENT_PROFILES.find(p => p.id === selectedProfileId) || DEFAULT_AGENT_PROFILES[0], + [selectedProfileId] + ); + + // Get profile's default phase config + const profilePhaseModels = selectedProfile.phaseModels || DEFAULT_PHASE_MODELS; + const profilePhaseThinking = selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING; + + // Get current phase config from settings (custom) or fall back to profile defaults + const currentPhaseModels: PhaseModelConfig = settings.customPhaseModels || profilePhaseModels; + const currentPhaseThinking: PhaseThinkingConfig = settings.customPhaseThinking || profilePhaseThinking; + + /** + * Check if current config differs from the selected profile's defaults + */ + const hasCustomConfig = useMemo((): boolean => { + if (!settings.customPhaseModels && !settings.customPhaseThinking) { + return false; // No custom settings, using profile defaults + } + return PHASE_KEYS.some( + phase => + currentPhaseModels[phase] !== profilePhaseModels[phase] || + currentPhaseThinking[phase] !== profilePhaseThinking[phase] + ); + }, [settings.customPhaseModels, settings.customPhaseThinking, currentPhaseModels, currentPhaseThinking, profilePhaseModels, profilePhaseThinking]); const handleSelectProfile = async (profileId: string) => { - const success = await saveSettings({ selectedAgentProfile: profileId }); + const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId); + if (!profile) return; + + // When selecting a preset, reset to that preset's defaults + const success = await saveSettings({ + selectedAgentProfile: profileId, + // Clear custom settings to use profile defaults + customPhaseModels: undefined, + customPhaseThinking: undefined + }); if (!success) { - // Log error for debugging - in future could show user toast notification console.error('Failed to save agent profile selection'); return; } - // Auto-expand phase config when Auto profile is selected - if (profileId === 'auto') { - setShowPhaseConfig(true); - } }; const handlePhaseModelChange = async (phase: keyof PhaseModelConfig, value: ModelTypeShort) => { + // Save as custom config (deviating from preset) const newPhaseModels = { ...currentPhaseModels, [phase]: value }; await saveSettings({ customPhaseModels: newPhaseModels }); }; const handlePhaseThinkingChange = async (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => { + // Save as custom config (deviating from preset) const newPhaseThinking = { ...currentPhaseThinking, [phase]: value }; await saveSettings({ customPhaseThinking: newPhaseThinking }); }; - const handleResetToDefaults = async () => { + const handleResetToProfileDefaults = async () => { + // Reset to the selected profile's defaults await saveSettings({ - customPhaseModels: DEFAULT_PHASE_MODELS, - customPhaseThinking: DEFAULT_PHASE_THINKING + customPhaseModels: undefined, + customPhaseThinking: undefined }); }; @@ -95,22 +127,12 @@ export function AgentProfileSettings() { return level?.label || thinkingValue; }; - /** - * Check if current phase config differs from defaults - */ - const hasCustomConfig = (): boolean => { - return PHASE_KEYS.some( - phase => - currentPhaseModels[phase] !== DEFAULT_PHASE_MODELS[phase] || - currentPhaseThinking[phase] !== DEFAULT_PHASE_THINKING[phase] - ); - }; - /** * Render a single profile card */ const renderProfileCard = (profile: AgentProfile) => { const isSelected = selectedProfileId === profile.id; + const isCustomized = isSelected && hasCustomConfig; const Icon = iconMap[profile.icon || 'Brain'] || Brain; return ( @@ -149,7 +171,14 @@ export function AgentProfileSettings() {
-

{profile.name}

+
+

{profile.name}

+ {isCustomized && ( + + {t('agentProfile.customized')} + + )} +

{profile.description}

@@ -187,110 +216,108 @@ export function AgentProfileSettings() { {DEFAULT_AGENT_PROFILES.map(renderProfileCard)}
- {/* Phase Configuration (only for Auto profile) */} - {selectedProfileId === 'auto' && ( -
- {/* Header - Collapsible */} - + + {/* Phase Configuration Content */} + {showPhaseConfig && ( +
+ {/* Reset button - shown when customized */} + {hasCustomConfig && ( +
+ +
)} - - {/* Phase Configuration Content */} - {showPhaseConfig && ( -
- {/* Reset button */} - {hasCustomConfig() && ( -
- -
- )} - - {/* Phase Configuration Grid */} -
- {PHASE_KEYS.map((phase) => ( -
-
- - - {t(`agentProfile.phases.${phase}.description`)} - + {/* Phase Configuration Grid */} +
+ {PHASE_KEYS.map((phase) => ( +
+
+ + + {t(`agentProfile.phases.${phase}.description`)} + +
+
+ {/* Model Select */} +
+ +
-
- {/* Model Select */} -
- - -
- {/* Thinking Level Select */} -
- - -
+ {/* Thinking Level Select */} +
+ +
- ))} -
- - {/* Info note */} -

- {t('agentProfile.phaseConfigNote')} -

+
+ ))}
- )} -
- )} + + {/* Info note */} +

+ {t('agentProfile.phaseConfigNote')} +

+
+ )} +
diff --git a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx index 260c9f73..f1fbdeae 100644 --- a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx +++ b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx @@ -16,7 +16,7 @@ * - Request cancellation: aborts pending fetch when closed */ import { useState, useEffect, useRef } from 'react'; -import { Loader2, AlertCircle, ChevronDown, Search, Check, Info } from 'lucide-react'; +import { Loader2, ChevronDown, Search, Check, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; @@ -80,7 +80,7 @@ export function ModelSearchableSelect({ const [searchQuery, setSearchQuery] = useState(''); // Manual input mode (when API doesn't support model listing) - const [isManualInput, setIsManualInput] = useState(false); + const [_isManualInput, setIsManualInput] = useState(false); // AbortController for cancelling fetch requests const abortControllerRef = useRef(null); diff --git a/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts b/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts index ab012d21..3a911606 100644 --- a/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts +++ b/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts @@ -222,19 +222,9 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { } }, [task.id]); - // Auto-load merge preview when worktree is ready (eliminates need to click "Check Conflicts") - // NOTE: This must be placed AFTER loadMergePreview definition since it depends on that callback - useEffect(() => { - // Only auto-load if: - // 1. Task needs review - // 2. Worktree exists - // 3. We haven't already loaded the preview for this task - // 4. We're not currently loading - const alreadyLoaded = hasLoadedPreviewRef.current === task.id; - if (needsReview && worktreeStatus?.exists && !alreadyLoaded && !isLoadingPreview) { - loadMergePreview(); - } - }, [needsReview, worktreeStatus?.exists, isLoadingPreview, task.id, loadMergePreview]); + // NOTE: Merge preview is NO LONGER auto-loaded on modal open. + // User must click "Check for Conflicts" button to trigger the expensive preview operation. + // This improves modal open performance significantly (avoids 1-30+ second Python subprocess). return { // State diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx index ad286d10..b474c914 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx @@ -367,41 +367,72 @@ export function WorkspaceStatus({ {/* Actions Footer */}
- {/* Stage Only Option */} - + {/* Stage Only Option - only show after conflicts have been checked */} + {mergePreview && ( + + )} {/* Primary Actions */}
- + {/* State 1: No merge preview yet - show "Check for Conflicts" */} + {!mergePreview && !isLoadingPreview && ( + + )} + + {/* State 2: Loading merge preview */} + {isLoadingPreview && ( + + )} + + {/* State 3: Merge preview loaded - show appropriate merge/stage button */} + {mergePreview && !isLoadingPreview && ( + + )} + + )} diff --git a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx index 7688edc2..d9b449e3 100644 --- a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx +++ b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { FolderGit, Plus, ChevronDown, Loader2 } from 'lucide-react'; +import { FolderGit, Plus, ChevronDown, Loader2, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TerminalWorktreeConfig } from '../../../shared/types'; import { @@ -9,6 +9,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; import { cn } from '../../lib/utils'; interface WorktreeSelectorProps { @@ -33,35 +43,64 @@ export function WorktreeSelector({ const [worktrees, setWorktrees] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); + const [deleteWorktree, setDeleteWorktree] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); // Fetch worktrees when dropdown opens + const fetchWorktrees = async () => { + if (!projectPath) return; + setIsLoading(true); + try { + const result = await window.electronAPI.listTerminalWorktrees(projectPath); + if (result.success && result.data) { + // Filter out the current worktree from the list + const available = currentWorktree + ? result.data.filter((wt) => wt.name !== currentWorktree.name) + : result.data; + setWorktrees(available); + } + } catch (err) { + console.error('Failed to fetch worktrees:', err); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { if (isOpen && projectPath) { - const fetchWorktrees = async () => { - setIsLoading(true); - try { - const result = await window.electronAPI.listTerminalWorktrees(projectPath); - if (result.success && result.data) { - // Filter out the current worktree from the list - const available = currentWorktree - ? result.data.filter((wt) => wt.name !== currentWorktree.name) - : result.data; - setWorktrees(available); - } - } catch (err) { - console.error('Failed to fetch worktrees:', err); - } finally { - setIsLoading(false); - } - }; fetchWorktrees(); } }, [isOpen, projectPath, currentWorktree]); + // Handle delete worktree + const handleDeleteWorktree = async () => { + if (!deleteWorktree || !projectPath) return; + setIsDeleting(true); + try { + const result = await window.electronAPI.removeTerminalWorktree( + projectPath, + deleteWorktree.name, + deleteWorktree.hasGitBranch // Delete the branch too if it was created + ); + if (result.success) { + // Refresh the list + await fetchWorktrees(); + } else { + console.error('Failed to delete worktree:', result.error); + } + } catch (err) { + console.error('Failed to delete worktree:', err); + } finally { + setIsDeleting(false); + setDeleteWorktree(null); + } + }; + // If terminal already has a worktree, show worktree badge (handled in TerminalHeader) // This component only shows when there's no worktree attached return ( + <> ))} ) : null} + + {/* Delete Confirmation Dialog */} + !open && setDeleteWorktree(null)}> + + + {t('terminal:worktree.deleteTitle', 'Delete Worktree?')} + + {t('terminal:worktree.deleteDescription', 'This will permanently delete the worktree and its branch. Any uncommitted changes will be lost.')} + {deleteWorktree && ( + + {deleteWorktree.name} + {deleteWorktree.branchName && ( + ({deleteWorktree.branchName}) + )} + + )} + + + + {t('common:cancel')} + + {isDeleting ? ( + <> + + {t('common:deleting', 'Deleting...')} + + ) : ( + <> + + {t('common:delete')} + + )} + + + + + ); } diff --git a/apps/frontend/src/renderer/components/terminal/types.ts b/apps/frontend/src/renderer/components/terminal/types.ts index fe3cee08..aa965d13 100644 --- a/apps/frontend/src/renderer/components/terminal/types.ts +++ b/apps/frontend/src/renderer/components/terminal/types.ts @@ -1,3 +1,4 @@ +import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; import type { Task, ExecutionPhase } from '../../../shared/types'; import type { TerminalStatus } from '../../stores/terminal-store'; import { Circle, Search, Code2, Wrench, CheckCircle2, AlertCircle } from 'lucide-react'; @@ -12,6 +13,14 @@ export interface TerminalProps { tasks?: Task[]; onNewTaskClick?: () => void; terminalCount?: number; + /** Drag handle listeners from useSortable for terminal reordering */ + dragHandleListeners?: SyntheticListenerMap; + /** Whether this terminal is currently being dragged */ + isDragging?: boolean; + /** Whether the terminal is expanded to full view */ + isExpanded?: boolean; + /** Callback to toggle expanded state */ + onToggleExpand?: () => void; } /** @@ -19,11 +28,11 @@ export interface TerminalProps { * More terminals = narrower title to fit all elements. */ export function getTitleMaxWidthClass(terminalCount: number): string { - if (terminalCount <= 2) return 'max-w-64'; // 256px - large - if (terminalCount <= 4) return 'max-w-48'; // 192px - medium - if (terminalCount <= 6) return 'max-w-40'; // 160px - default - if (terminalCount <= 9) return 'max-w-32'; // 128px - compact - return 'max-w-24'; // 96px - very compact for 10-12 terminals + if (terminalCount <= 2) return 'max-w-72'; // 288px - large + if (terminalCount <= 4) return 'max-w-56'; // 224px - medium + if (terminalCount <= 6) return 'max-w-48'; // 192px - default + if (terminalCount <= 9) return 'max-w-40'; // 160px - compact + return 'max-w-36'; // 144px - compact for 10-12 terminals } export const STATUS_COLORS: Record = { diff --git a/apps/frontend/src/renderer/components/terminal/useAutoNaming.ts b/apps/frontend/src/renderer/components/terminal/useAutoNaming.ts index 1b1eb992..d9b1122b 100644 --- a/apps/frontend/src/renderer/components/terminal/useAutoNaming.ts +++ b/apps/frontend/src/renderer/components/terminal/useAutoNaming.ts @@ -62,6 +62,8 @@ export function useAutoNaming({ terminalId, cwd }: UseAutoNamingOptions) { const result = await window.electronAPI.generateTerminalName(command, terminal?.cwd || cwd); if (result.success && result.data) { updateTerminal(terminalId, { title: result.data }); + // Sync to main process so title persists across hot reloads + window.electronAPI.setTerminalTitle(terminalId, result.data); } } catch (error) { console.warn('[Terminal] Auto-naming failed:', error); diff --git a/apps/frontend/src/renderer/components/terminal/usePtyProcess.ts b/apps/frontend/src/renderer/components/terminal/usePtyProcess.ts index c40ee99f..e50f433c 100644 --- a/apps/frontend/src/renderer/components/terminal/usePtyProcess.ts +++ b/apps/frontend/src/renderer/components/terminal/usePtyProcess.ts @@ -7,6 +7,7 @@ interface UsePtyProcessOptions { projectPath?: string; cols: number; rows: number; + skipCreation?: boolean; // Skip PTY creation until dimensions are ready onCreated?: () => void; onError?: (error: string) => void; } @@ -17,6 +18,7 @@ export function usePtyProcess({ projectPath, cols, rows, + skipCreation = false, onCreated, onError, }: UsePtyProcessOptions) { @@ -44,6 +46,8 @@ export function usePtyProcess({ // Create PTY process useEffect(() => { + // Skip creation if explicitly told to (waiting for dimensions) + if (skipCreation) return; if (isCreatingRef.current || isCreatedRef.current) return; const terminalState = useTerminalStore.getState().terminals.find((t) => t.id === terminalId); @@ -107,7 +111,7 @@ export function usePtyProcess({ isCreatingRef.current = false; }); } - }, [terminalId, cwd, projectPath, cols, rows, setTerminalStatus, updateTerminal, onCreated, onError]); + }, [terminalId, cwd, projectPath, cols, rows, skipCreation, setTerminalStatus, updateTerminal, onCreated, onError]); // Function to prepare for recreation by preventing the effect from running // Call this BEFORE updating the store cwd to avoid race condition diff --git a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts index a12a400b..5e13d8fe 100644 --- a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts +++ b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts @@ -58,8 +58,32 @@ export function useTerminalEvents({ useEffect(() => { const cleanup = window.electronAPI.onTerminalExit((id, exitCode) => { if (id === terminalId) { - useTerminalStore.getState().setTerminalStatus(terminalId, 'exited'); + const store = useTerminalStore.getState(); + store.setTerminalStatus(terminalId, 'exited'); + // Reset Claude mode when terminal exits - the Claude process has ended + // Use updateTerminal instead of setClaudeMode to avoid changing status back to 'running' + const terminal = store.getTerminal(terminalId); + if (terminal?.isClaudeMode) { + store.updateTerminal(terminalId, { isClaudeMode: false }); + } onExitRef.current?.(exitCode); + + // Auto-remove exited terminals from store after a short delay + // This prevents them from counting toward the max terminal limit + // and ensures they don't get persisted and restored on next launch + setTimeout(() => { + const currentStore = useTerminalStore.getState(); + const currentTerminal = currentStore.getTerminal(terminalId); + // Only remove if still exited (user hasn't recreated it) + if (currentTerminal?.status === 'exited') { + // First call destroyTerminal to clean up persisted session on disk + // (the PTY is already dead, but this ensures session removal) + window.electronAPI.destroyTerminal(terminalId).catch(() => { + // Ignore errors - PTY may already be gone + }); + currentStore.removeTerminal(terminalId); + } + }, 2000); // 2 second delay to show exit message } }); @@ -82,7 +106,11 @@ export function useTerminalEvents({ useEffect(() => { const cleanup = window.electronAPI.onTerminalClaudeSession((id, sessionId) => { if (id === terminalId) { - useTerminalStore.getState().setClaudeSessionId(terminalId, sessionId); + const store = useTerminalStore.getState(); + store.setClaudeSessionId(terminalId, sessionId); + // Also set Claude mode to true when we receive a session ID + // This ensures the Claude badge shows up after auto-resume + store.setClaudeMode(terminalId, true); console.warn('[Terminal] Captured Claude session ID:', sessionId); onClaudeSessionRef.current?.(sessionId); } @@ -90,4 +118,15 @@ export function useTerminalEvents({ return cleanup; }, [terminalId]); + + // Handle Claude busy state changes (for visual indicator) + useEffect(() => { + const cleanup = window.electronAPI.onTerminalClaudeBusy((id, isBusy) => { + if (id === terminalId) { + useTerminalStore.getState().setClaudeBusy(terminalId, isBusy); + } + }); + + return cleanup; + }, [terminalId]); } diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index e25c855e..87c0a7cd 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; @@ -9,15 +9,27 @@ interface UseXtermOptions { terminalId: string; onCommandEnter?: (command: string) => void; onResize?: (cols: number, rows: number) => void; + onDimensionsReady?: (cols: number, rows: number) => void; } -export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptions) { +// Debounce helper function +function debounce void>(fn: T, ms: number): T { + let timeoutId: ReturnType | null = null; + return ((...args: unknown[]) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), ms); + }) as T; +} + +export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsReady }: UseXtermOptions) { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const serializeAddonRef = useRef(null); const commandBufferRef = useRef(''); const isDisposedRef = useRef(false); + const dimensionsReadyCalledRef = useRef(false); + const [dimensions, setDimensions] = useState<{ cols: number; rows: number }>({ cols: 80, rows: 24 }); // Initialize xterm.js UI useEffect(() => { @@ -109,14 +121,36 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio return true; }); - setTimeout(() => { - fitAddon.fit(); - }, 50); - xtermRef.current = xterm; fitAddonRef.current = fitAddon; serializeAddonRef.current = serializeAddon; + // Use requestAnimationFrame to wait for layout, then fit + // This is more reliable than a fixed timeout + const performInitialFit = () => { + requestAnimationFrame(() => { + if (fitAddonRef.current && xtermRef.current && terminalRef.current) { + // Check if container has valid dimensions + const rect = terminalRef.current.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + fitAddonRef.current.fit(); + const cols = xtermRef.current.cols; + const rows = xtermRef.current.rows; + setDimensions({ cols, rows }); + // Call onDimensionsReady once when we have valid dimensions + if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) { + dimensionsReadyCalledRef.current = true; + onDimensionsReady?.(cols, rows); + } + } else { + // Container not ready yet, retry after a short delay + setTimeout(performInitialFit, 50); + } + } + }); + }; + performInitialFit(); + // Replay buffered output if this is a remount or restored session // This now includes ANSI codes for proper formatting/colors/prompt const bufferedOutput = terminalBufferManager.get(terminalId); @@ -156,23 +190,36 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio return () => { // Cleanup handled by parent component }; - }, [terminalId, onCommandEnter, onResize]); + }, [terminalId, onCommandEnter, onResize, onDimensionsReady]); - // Handle resize on container resize + // Handle resize on container resize with debouncing useEffect(() => { - const handleResize = () => { - if (fitAddonRef.current && xtermRef.current) { - fitAddonRef.current.fit(); + const handleResize = debounce(() => { + if (fitAddonRef.current && xtermRef.current && terminalRef.current) { + // Check if container has valid dimensions before fitting + const rect = terminalRef.current.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + fitAddonRef.current.fit(); + const cols = xtermRef.current.cols; + const rows = xtermRef.current.rows; + setDimensions({ cols, rows }); + // Notify when dimensions become valid (for late PTY creation) + if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) { + dimensionsReadyCalledRef.current = true; + onDimensionsReady?.(cols, rows); + } + } } - }; + }, 100); // 100ms debounce to prevent layout thrashing - const container = terminalRef.current?.parentElement; + // Observe the terminalRef directly (not parent) for accurate resize detection + const container = terminalRef.current; if (container) { const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); return () => resizeObserver.disconnect(); } - }, []); + }, [onDimensionsReady]); const fit = useCallback(() => { if (fitAddonRef.current && xtermRef.current) { @@ -243,7 +290,8 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio writeln, focus, dispose, - cols: xtermRef.current?.cols || 80, - rows: xtermRef.current?.rows || 24, + cols: dimensions.cols, + rows: dimensions.rows, + dimensionsReady: dimensionsReadyCalledRef.current, }; } diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 41a94fb0..d2e58c85 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -197,6 +197,7 @@ const browserMockAPI: ElectronAPI = { onAutoFixComplete: () => () => {}, onAutoFixError: () => () => {}, listPRs: async () => [], + getPR: async () => null, runPRReview: () => {}, cancelPRReview: async () => true, postPRReview: async () => true, @@ -204,10 +205,13 @@ const browserMockAPI: ElectronAPI = { mergePR: async () => true, assignPR: async () => true, getPRReview: async () => null, + getPRReviewsBatch: async () => ({}), deletePRReview: async () => true, checkNewCommits: async () => ({ hasNewCommits: false, newCommitCount: 0 }), runFollowupReview: () => {}, getPRLogs: async () => null, + getWorkflowsAwaitingApproval: async () => ({ awaiting_approval: 0, workflow_runs: [], can_approve: false }), + approveWorkflow: async () => true, onPRReviewProgress: () => () => {}, onPRReviewComplete: () => () => {}, onPRReviewError: () => () => {}, diff --git a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts index c74943e5..6faf96d7 100644 --- a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts @@ -30,6 +30,14 @@ export const terminalMock = { data: 'Mock Terminal' }), + setTerminalTitle: () => { + console.warn('[Browser Mock] setTerminalTitle called'); + }, + + setTerminalWorktreeConfig: () => { + console.warn('[Browser Mock] setTerminalWorktreeConfig called'); + }, + // Terminal session management getTerminalSessions: async () => ({ success: true, @@ -82,5 +90,6 @@ export const terminalMock = { onTerminalTitleChange: () => () => {}, onTerminalClaudeSession: () => () => {}, onTerminalRateLimit: () => () => {}, - onTerminalOAuthToken: () => () => {} + onTerminalOAuthToken: () => () => {}, + onTerminalClaudeBusy: () => () => {} }; diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 2f7c7a52..02adc498 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { v4 as uuid } from 'uuid'; +import { arrayMove } from '@dnd-kit/sortable'; import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types'; import { terminalBufferManager } from '../lib/terminal-buffer-manager'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; @@ -19,6 +20,7 @@ export interface Terminal { associatedTaskId?: string; // ID of task associated with this terminal (for context loading) projectPath?: string; // Project this terminal belongs to (for multi-project support) worktreeConfig?: TerminalWorktreeConfig; // Associated worktree for isolated development + isClaudeBusy?: boolean; // Whether Claude Code is actively processing (for visual indicator) } interface TerminalLayout { @@ -47,13 +49,15 @@ interface TerminalState { setClaudeSessionId: (id: string, sessionId: string) => void; setAssociatedTask: (id: string, taskId: string | undefined) => void; setWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined) => void; + setClaudeBusy: (id: string, isBusy: boolean) => void; clearAllTerminals: () => void; setHasRestoredSessions: (value: boolean) => void; + reorderTerminals: (activeId: string, overId: string) => void; // Selectors getTerminal: (id: string) => Terminal | undefined; getActiveTerminal: () => Terminal | undefined; - canAddTerminal: () => boolean; + canAddTerminal: (projectPath?: string) => boolean; getTerminalsForProject: (projectPath: string) => Terminal[]; getWorktreeCount: () => number; } @@ -105,11 +109,15 @@ export const useTerminalStore = create((set, get) => ({ status: 'idle', // Will be updated to 'running' when PTY is created cwd: session.cwd, createdAt: new Date(session.createdAt), - isClaudeMode: session.isClaudeMode, + // Reset Claude mode to false - Claude Code is killed on app restart + // Keep claudeSessionId so users can resume by clicking the invoke button + isClaudeMode: false, claudeSessionId: session.claudeSessionId, // outputBuffer now stored in terminalBufferManager isRestored: true, projectPath: session.projectPath, + // Worktree config is validated in main process before restore + worktreeConfig: session.worktreeConfig, }; // Restore buffer to buffer manager @@ -166,7 +174,13 @@ export const useTerminalStore = create((set, get) => ({ set((state) => ({ terminals: state.terminals.map((t) => t.id === id - ? { ...t, isClaudeMode, status: isClaudeMode ? 'claude-active' : 'running' } + ? { + ...t, + isClaudeMode, + status: isClaudeMode ? 'claude-active' : 'running', + // Reset busy state when leaving Claude mode + isClaudeBusy: isClaudeMode ? t.isClaudeBusy : undefined + } : t ), })); @@ -196,6 +210,14 @@ export const useTerminalStore = create((set, get) => ({ })); }, + setClaudeBusy: (id: string, isBusy: boolean) => { + set((state) => ({ + terminals: state.terminals.map((t) => + t.id === id ? { ...t, isClaudeBusy: isBusy } : t + ), + })); + }, + clearAllTerminals: () => { set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false }); }, @@ -204,6 +226,21 @@ export const useTerminalStore = create((set, get) => ({ set({ hasRestoredSessions: value }); }, + reorderTerminals: (activeId: string, overId: string) => { + set((state) => { + const oldIndex = state.terminals.findIndex((t) => t.id === activeId); + const newIndex = state.terminals.findIndex((t) => t.id === overId); + + if (oldIndex === -1 || newIndex === -1) { + return state; + } + + return { + terminals: arrayMove(state.terminals, oldIndex, newIndex), + }; + }); + }, + getTerminal: (id: string) => { return get().terminals.find((t) => t.id === id); }, @@ -213,9 +250,19 @@ export const useTerminalStore = create((set, get) => ({ return state.terminals.find((t) => t.id === state.activeTerminalId); }, - canAddTerminal: () => { + canAddTerminal: (projectPath?: string) => { const state = get(); - return state.terminals.length < state.maxTerminals; + // Count only non-exited terminals, optionally filtered by project + const activeTerminals = state.terminals.filter(t => { + // Exclude exited terminals from the count + if (t.status === 'exited') return false; + // If projectPath specified, only count terminals for that project (or legacy without projectPath) + if (projectPath) { + return t.projectPath === projectPath || !t.projectPath; + } + return true; + }); + return activeTerminals.length < state.maxTerminals; }, getTerminalsForProject: (projectPath: string) => { diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 6c244d6d..6e59bef4 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -63,6 +63,8 @@ export const IPC_CHANNELS = { TERMINAL_RESIZE: 'terminal:resize', TERMINAL_INVOKE_CLAUDE: 'terminal:invokeClaude', TERMINAL_GENERATE_NAME: 'terminal:generateName', + TERMINAL_SET_TITLE: 'terminal:setTitle', // Renderer -> Main: user renamed terminal + TERMINAL_SET_WORKTREE_CONFIG: 'terminal:setWorktreeConfig', // Renderer -> Main: worktree association changed // Terminal session management TERMINAL_GET_SESSIONS: 'terminal:getSessions', @@ -86,6 +88,7 @@ export const IPC_CHANNELS = { TERMINAL_CLAUDE_SESSION: 'terminal:claudeSession', // Claude session ID captured TERMINAL_RATE_LIMIT: 'terminal:rateLimit', // Claude Code rate limit detected TERMINAL_OAUTH_TOKEN: 'terminal:oauthToken', // OAuth token captured from setup-token output + TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator) // Claude profile management (multi-account support) CLAUDE_PROFILES_GET: 'claude:profilesGet', @@ -353,6 +356,7 @@ export const IPC_CHANNELS = { GITHUB_PR_REVIEW: 'github:pr:review', GITHUB_PR_REVIEW_CANCEL: 'github:pr:reviewCancel', GITHUB_PR_GET_REVIEW: 'github:pr:getReview', + GITHUB_PR_GET_REVIEWS_BATCH: 'github:pr:getReviewsBatch', // Batch load reviews for multiple PRs GITHUB_PR_POST_REVIEW: 'github:pr:postReview', GITHUB_PR_DELETE_REVIEW: 'github:pr:deleteReview', GITHUB_PR_MERGE: 'github:pr:merge', @@ -370,6 +374,14 @@ export const IPC_CHANNELS = { // GitHub PR Logs (for viewing AI review logs) GITHUB_PR_GET_LOGS: 'github:pr:getLogs', + // GitHub PR Memory operations (saves review insights to memory layer) + GITHUB_PR_MEMORY_GET: 'github:pr:memory:get', // Get PR review memories + GITHUB_PR_MEMORY_SEARCH: 'github:pr:memory:search', // Search PR review memories + + // GitHub Workflow Approval (for fork PRs) + GITHUB_WORKFLOWS_AWAITING_APPROVAL: 'github:workflows:awaitingApproval', + GITHUB_WORKFLOW_APPROVE: 'github:workflow:approve', + // GitHub Issue Triage operations GITHUB_TRIAGE_RUN: 'github:triage:run', GITHUB_TRIAGE_GET_RESULTS: 'github:triage:getResults', diff --git a/apps/frontend/src/shared/constants/models.ts b/apps/frontend/src/shared/constants/models.ts index 4c8a31e1..ddff3792 100644 --- a/apps/frontend/src/shared/constants/models.ts +++ b/apps/frontend/src/shared/constants/models.ts @@ -45,26 +45,76 @@ export const THINKING_LEVELS = [ ] as const; // ============================================ -// Agent Profiles +// Agent Profiles - Phase Configurations // ============================================ -// Default phase model configuration for Auto profile -// Uses Opus across all phases for maximum quality -export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { - spec: 'opus', // Best quality for spec creation - planning: 'opus', // Complex architecture decisions benefit from Opus - coding: 'opus', // Highest quality implementation - qa: 'opus' // Thorough QA review +// Phase configurations for each preset profile +// Each profile has its own default phase models and thinking levels + +// Auto (Optimized) - Opus with optimized thinking per phase +export const AUTO_PHASE_MODELS: PhaseModelConfig = { + spec: 'opus', + planning: 'opus', + coding: 'opus', + qa: 'opus' }; -// Default phase thinking configuration for Auto profile -export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { +export const AUTO_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { spec: 'ultrathink', // Deep thinking for comprehensive spec creation planning: 'high', // High thinking for planning complex features coding: 'low', // Faster coding iterations qa: 'low' // Efficient QA review }; +// Complex Tasks - Opus with ultrathink across all phases +export const COMPLEX_PHASE_MODELS: PhaseModelConfig = { + spec: 'opus', + planning: 'opus', + coding: 'opus', + qa: 'opus' +}; + +export const COMPLEX_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { + spec: 'ultrathink', + planning: 'ultrathink', + coding: 'ultrathink', + qa: 'ultrathink' +}; + +// Balanced - Sonnet with medium thinking across all phases +export const BALANCED_PHASE_MODELS: PhaseModelConfig = { + spec: 'sonnet', + planning: 'sonnet', + coding: 'sonnet', + qa: 'sonnet' +}; + +export const BALANCED_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { + spec: 'medium', + planning: 'medium', + coding: 'medium', + qa: 'medium' +}; + +// Quick Edits - Haiku with low thinking across all phases +export const QUICK_PHASE_MODELS: PhaseModelConfig = { + spec: 'haiku', + planning: 'haiku', + coding: 'haiku', + qa: 'haiku' +}; + +export const QUICK_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { + spec: 'low', + planning: 'low', + coding: 'low', + qa: 'low' +}; + +// Default phase configuration (used for fallback, matches 'Balanced' profile for cost-effectiveness) +export const DEFAULT_PHASE_MODELS: PhaseModelConfig = BALANCED_PHASE_MODELS; +export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = BALANCED_PHASE_THINKING; + // ============================================ // Feature Settings (Non-Pipeline Features) // ============================================ @@ -100,17 +150,17 @@ export const FEATURE_LABELS: Record void; invokeClaudeInTerminal: (id: string, cwd?: string) => void; generateTerminalName: (command: string, cwd?: string) => Promise>; + setTerminalTitle: (id: string, title: string) => void; + setTerminalWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined) => void; // Terminal session management (persistence/restore) getTerminalSessions: (projectPath: string) => Promise>; @@ -223,6 +225,8 @@ export interface ElectronAPI { message?: string; detectedAt: string }) => void) => () => void; + /** Listen for Claude busy state changes (for visual indicator: red=busy, green=idle) */ + onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void; // Claude profile management (multi-account support) getClaudeProfiles: () => Promise>; diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index 2e2e4b0c..5ac23e84 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -246,13 +246,29 @@ export interface GraphitiMemoryState { error_log: Array<{ timestamp: string; error: string }>; } +export type MemoryType = + | 'session_insight' + | 'codebase_discovery' + | 'codebase_map' + | 'pattern' + | 'gotcha' + | 'task_outcome' + | 'pr_review' + | 'pr_finding' + | 'pr_pattern' + | 'pr_gotcha'; + export interface MemoryEpisode { id: string; - type: 'session_insight' | 'codebase_discovery' | 'codebase_map' | 'pattern' | 'gotcha' | 'task_outcome'; + type: MemoryType; timestamp: string; content: string; session_number?: number; score?: number; + // For PR reviews - extracted from content for quick access + prNumber?: number; + repo?: string; + verdict?: 'approve' | 'request_changes' | 'comment'; } export interface ContextSearchResult { diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index ac666304..5f41c78e 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -201,17 +201,19 @@ export interface FeatureThinkingConfig { } // Agent profile for preset model/thinking configurations +// All profiles have per-phase configuration (phaseModels/phaseThinking) export interface AgentProfile { id: string; name: string; description: string; - model: ModelTypeShort; - thinkingLevel: ThinkingLevel; - icon?: string; // Lucide icon name - // Auto profile specific - per-phase configuration - isAutoProfile?: boolean; + model: ModelTypeShort; // Primary model (shown in profile card) + thinkingLevel: ThinkingLevel; // Primary thinking level (shown in profile card) + icon?: string; // Lucide icon name + // Per-phase configuration - all profiles now have this phaseModels?: PhaseModelConfig; phaseThinking?: PhaseThinkingConfig; + /** @deprecated Use phaseModels and phaseThinking for per-phase configuration. Will be removed in v3.0. */ + isAutoProfile?: boolean; } export interface AppSettings { @@ -246,6 +248,9 @@ export interface AppSettings { memoryAzureApiKey?: string; memoryAzureBaseUrl?: string; memoryAzureEmbeddingDeployment?: string; + // Agent Memory Access (MCP) - app-wide defaults + graphitiMcpEnabled?: boolean; + graphitiMcpUrl?: string; // Onboarding wizard completion state onboardingCompleted?: boolean; // Selected agent profile for preset model/thinking configurations diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index ac6ce82e..fc7eb930 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -29,6 +29,8 @@ export interface TerminalSession { outputBuffer: string; createdAt: string; lastActiveAt: string; + /** Associated worktree configuration (validated on restore) */ + worktreeConfig?: TerminalWorktreeConfig; } export interface TerminalRestoreResult { diff --git a/package-lock.json b/package-lock.json index 074e974b..1d509585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "electron-updater": "^6.6.2", "i18next": "^25.7.3", "lucide-react": "^0.562.0", + "minimatch": "^10.1.1", "motion": "^12.23.26", "proper-lockfile": "^4.1.2", "react": "^19.2.3", @@ -85,6 +86,7 @@ "@tailwindcss/postcss": "^4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", + "@types/minimatch": "^5.1.2", "@types/node": "^25.0.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -2406,7 +2408,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, "license": "MIT", "engines": { "node": "20 || >=22" @@ -2416,7 +2417,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -5675,6 +5675,13 @@ "@types/unist": "*" } }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -12756,7 +12763,6 @@ "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" diff --git a/scripts/bump-version.js b/scripts/bump-version.js index 5736eb07..00f6a0d7 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -160,11 +160,20 @@ function checkChangelogEntry(version) { const content = fs.readFileSync(changelogPath, 'utf8'); - // Look for "## X.Y.Z" or "## X.Y.Z -" header - const versionPattern = new RegExp(`^## ${version.replace(/\./g, '\\.')}(\\s|-)`, 'm'); + // Look for "## X.Y.Z" or "## X.Y.Z -" header using string matching + // This avoids regex injection concerns from user-provided version strings + const lines = content.split('\n'); + const versionHeaderPrefix = `## ${version}`; - if (versionPattern.test(content)) { - return true; + for (const line of lines) { + // Check if line starts with "## X.Y.Z" followed by whitespace, dash, or end of line + if (line.startsWith(versionHeaderPrefix)) { + const afterVersion = line.slice(versionHeaderPrefix.length); + // Valid if nothing follows, or whitespace/dash follows + if (afterVersion === '' || afterVersion[0] === ' ' || afterVersion[0] === '-' || afterVersion[0] === '\t') { + return true; + } + } } return false; diff --git a/tests/test_finding_validation.py b/tests/test_finding_validation.py index 6dd9f2d0..edf6b779 100644 --- a/tests/test_finding_validation.py +++ b/tests/test_finding_validation.py @@ -36,10 +36,8 @@ from pydantic_models import ( ) from models import ( PRReviewFinding, - PRReviewResult, ReviewSeverity, ReviewCategory, - MergeVerdict, ) diff --git a/tests/test_github_pr_review.py b/tests/test_github_pr_review.py index a602500a..25b72fe4 100644 --- a/tests/test_github_pr_review.py +++ b/tests/test_github_pr_review.py @@ -227,6 +227,54 @@ class TestFollowupReviewContext: assert context.error is not None assert "Failed to compare commits" in context.error + def test_context_rebase_detected_files_changed_no_commits(self, sample_review_result): + """Test follow-up context when PR was rebased (files changed but no trackable commits). + + After a rebase/force-push, commit SHAs are rewritten so we can't identify "new" commits. + However, blob SHA comparison can still identify which files actually changed content. + The follow-up review should proceed based on file changes, not skip the review. + """ + context = FollowupReviewContext( + pr_number=123, + previous_review=sample_review_result, + previous_commit_sha="abc123", # This SHA no longer exists in PR after rebase + current_commit_sha="xyz789", + commits_since_review=[], # Empty after rebase - can't determine "new" commits + files_changed_since_review=["src/db.py", "src/api.py"], # But blob comparison found changes + diff_since_review="--- a/src/db.py\n+++ b/src/db.py\n@@ -1,3 +1,3 @@\n-old\n+new", + ) + + # Verify context reflects rebase scenario + assert context.pr_number == 123 + assert len(context.commits_since_review) == 0 # No trackable commits + assert len(context.files_changed_since_review) == 2 # But files did change + assert context.error is None + + # The key assertion: this context should NOT be treated as "no changes" + # The orchestrator should check both commits AND files + has_changes = bool(context.commits_since_review) or bool( + context.files_changed_since_review + ) + assert has_changes is True, "Rebase with file changes should be treated as having changes" + + def test_context_truly_no_changes(self, sample_review_result): + """Test follow-up context when there are truly no changes (same SHA, no files).""" + context = FollowupReviewContext( + pr_number=123, + previous_review=sample_review_result, + previous_commit_sha="abc123", + current_commit_sha="abc123", # Same SHA + commits_since_review=[], + files_changed_since_review=[], # No file changes either + diff_since_review="", + ) + + # This should be treated as no changes + has_changes = bool(context.commits_since_review) or bool( + context.files_changed_since_review + ) + assert has_changes is False, "No commits and no file changes means no changes" + # ============================================================================ # Bot Detection Integration Tests diff --git a/tests/test_project_analyzer.py b/tests/test_project_analyzer.py index bc99b2c8..df0dfaf1 100644 --- a/tests/test_project_analyzer.py +++ b/tests/test_project_analyzer.py @@ -18,10 +18,6 @@ from pathlib import Path import pytest from project_analyzer import ( BASE_COMMANDS, - DATABASE_COMMANDS, - FRAMEWORK_COMMANDS, - INFRASTRUCTURE_COMMANDS, - LANGUAGE_COMMANDS, CustomScripts, ProjectAnalyzer, SecurityProfile, diff --git a/tests/test_security.py b/tests/test_security.py index 6f0a281c..f635659c 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -21,6 +21,7 @@ from security import ( validate_chmod_command, validate_rm_command, validate_git_commit, + validate_git_config, validate_dropdb_command, validate_dropuser_command, validate_psql_command, @@ -393,6 +394,151 @@ class TestGitCommitValidator: assert allowed is True +class TestGitConfigValidator: + """Tests for git config validation (blocking identity changes).""" + + def test_blocks_user_name(self): + """Blocks git config user.name.""" + allowed, reason = validate_git_config("git config user.name 'Test User'") + assert allowed is False + assert "BLOCKED" in reason + assert "identity" in reason.lower() + + def test_blocks_user_email(self): + """Blocks git config user.email.""" + allowed, reason = validate_git_config("git config user.email 'test@example.com'") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_author_name(self): + """Blocks git config author.name.""" + allowed, reason = validate_git_config("git config author.name 'Fake Author'") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_committer_email(self): + """Blocks git config committer.email.""" + allowed, reason = validate_git_config("git config committer.email 'fake@test.com'") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_with_global_flag(self): + """Blocks identity config even with --global flag.""" + allowed, reason = validate_git_config("git config --global user.name 'Test User'") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_with_local_flag(self): + """Blocks identity config even with --local flag.""" + allowed, reason = validate_git_config("git config --local user.email 'test@example.com'") + assert allowed is False + assert "BLOCKED" in reason + + def test_allows_non_identity_config(self): + """Allows setting non-identity config options.""" + allowed, reason = validate_git_config("git config core.autocrlf true") + assert allowed is True + + allowed, reason = validate_git_config("git config diff.algorithm patience") + assert allowed is True + + allowed, reason = validate_git_config("git config pull.rebase true") + assert allowed is True + + def test_allows_config_list(self): + """Allows git config --list and similar read operations.""" + allowed, reason = validate_git_config("git config --list") + assert allowed is True + + allowed, reason = validate_git_config("git config --get user.name") + assert allowed is True + + def test_allows_non_config_commands(self): + """Non-config git commands pass through.""" + allowed, reason = validate_git_config("git status") + assert allowed is True + + allowed, reason = validate_git_config("git commit -m 'test'") + assert allowed is True + + def test_case_insensitive_blocking(self): + """Blocks identity keys regardless of case.""" + allowed, reason = validate_git_config("git config USER.NAME 'Test'") + assert allowed is False + + allowed, reason = validate_git_config("git config User.Email 'test@test.com'") + assert allowed is False + + def test_handles_malformed_command(self): + """Handles malformed commands gracefully.""" + # Unbalanced quotes - should fail closed + allowed, reason = validate_git_config("git config user.name 'Test User") + assert allowed is False + assert "parse" in reason.lower() + + +class TestGitIdentityProtection: + """Tests for git identity protection (blocking -c flag bypass).""" + + def test_blocks_inline_user_name(self): + """Blocks git -c user.name=... on any command.""" + allowed, reason = validate_git_commit("git -c user.name=Evil commit -m 'test'") + assert allowed is False + assert "BLOCKED" in reason + assert "identity" in reason.lower() + + def test_blocks_inline_user_email(self): + """Blocks git -c user.email=... on any command.""" + allowed, reason = validate_git_commit("git -c user.email=fake@test.com commit -m 'test'") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_inline_author_name(self): + """Blocks git -c author.name=... on any command.""" + allowed, reason = validate_git_commit("git -c author.name=FakeAuthor push") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_inline_committer_email(self): + """Blocks git -c committer.email=... on any command.""" + allowed, reason = validate_git_commit("git -c committer.email=fake@test.com log") + assert allowed is False + assert "BLOCKED" in reason + + def test_blocks_nospace_format(self): + """Blocks -ckey=value format (no space after -c).""" + allowed, reason = validate_git_commit("git -cuser.name=Evil commit -m 'test'") + assert allowed is False + assert "BLOCKED" in reason + + def test_allows_non_identity_config(self): + """Allows -c with non-blocked config keys.""" + allowed, reason = validate_git_commit("git -c core.autocrlf=true commit -m 'test'") + assert allowed is True + + allowed, reason = validate_git_commit("git -c diff.algorithm=patience diff") + assert allowed is True + + def test_allows_normal_git_commands(self): + """Normal git commands without -c identity flags pass.""" + allowed, reason = validate_git_commit("git status") + assert allowed is True + + allowed, reason = validate_git_commit("git log --oneline") + assert allowed is True + + allowed, reason = validate_git_commit("git branch -a") + assert allowed is True + + def test_case_insensitive_blocking(self): + """Blocks identity keys regardless of case.""" + allowed, reason = validate_git_commit("git -c USER.NAME=Evil commit -m 'test'") + assert allowed is False + + allowed, reason = validate_git_commit("git -c User.Email=fake@test.com push") + assert allowed is False + + # ============================================================================= # DATABASE VALIDATOR TESTS # ============================================================================= diff --git a/tests/test_worktree.py b/tests/test_worktree.py index 27de976f..1aa51600 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -16,7 +16,7 @@ from pathlib import Path import pytest -from worktree import WorktreeManager, WorktreeInfo, WorktreeError +from worktree import WorktreeManager class TestWorktreeManagerInitialization: